From 1f22b94fc1f0f3fe148b148bb2c8542808f266dc Mon Sep 17 00:00:00 2001 From: Keepers Date: Fri, 11 Aug 2023 17:02:45 -0600 Subject: [PATCH] small paths code rearrangement (#4007) small cleanup in paths, primarily splitting files so that file contents are more clearly owned, which should be a little better for readability and code placement. Also renames `ServicePrefix` to `BuildPrefix` in anticipation of multi-service prefixes. no logic changes, just movement/renaming. --- #### Does this PR need a docs update or release note? - [x] :no_entry: No #### Type of change - [x] :broom: Tech Debt/Cleanup #### Issue(s) * #3993 #### Test Plan - [x] :zap: Unit test - [x] :green_heart: E2E --- src/internal/kopia/base_finder.go | 2 +- src/internal/kopia/upload_test.go | 8 +- src/internal/m365/graph/collections.go | 2 +- src/internal/m365/graph/collections_test.go | 4 +- src/internal/m365/service/onedrive/backup.go | 4 +- .../restore_path_transformer.go | 2 +- src/internal/operations/test/exchange_test.go | 2 +- src/internal/operations/test/onedrive_test.go | 2 +- src/pkg/path/builder.go | 379 ++++++++++++++++ src/pkg/path/builder_test.go | 369 ++++++++++++++++ src/pkg/path/category_type.go | 108 +++++ src/pkg/path/drive.go | 1 + src/pkg/path/path.go | 408 +----------------- src/pkg/path/path_test.go | 346 +-------------- src/pkg/path/resource_path.go | 145 ------- src/pkg/path/service_type.go | 53 +++ src/pkg/selectors/reasons.go | 2 +- 17 files changed, 946 insertions(+), 891 deletions(-) create mode 100644 src/pkg/path/builder.go create mode 100644 src/pkg/path/builder_test.go create mode 100644 src/pkg/path/category_type.go create mode 100644 src/pkg/path/service_type.go diff --git a/src/internal/kopia/base_finder.go b/src/internal/kopia/base_finder.go index b44138004..83f4009c4 100644 --- a/src/internal/kopia/base_finder.go +++ b/src/internal/kopia/base_finder.go @@ -70,7 +70,7 @@ func (r reason) Category() path.CategoryType { } func (r reason) SubtreePath() (path.Path, error) { - p, err := path.ServicePrefix( + p, err := path.BuildPrefix( r.Tenant(), r.ProtectedResource(), r.Service(), diff --git a/src/internal/kopia/upload_test.go b/src/internal/kopia/upload_test.go index 6902a4af9..287381b26 100644 --- a/src/internal/kopia/upload_test.go +++ b/src/internal/kopia/upload_test.go @@ -2865,16 +2865,16 @@ func (suite *HierarchyBuilderUnitSuite) TestBuildDirectoryTreeSelectsMigrateSubt migratedUser = "user_migrate" ) - oldPrefixPathEmail, err := path.ServicePrefix(testTenant, testUser, path.ExchangeService, path.EmailCategory) + oldPrefixPathEmail, err := path.BuildPrefix(testTenant, testUser, path.ExchangeService, path.EmailCategory) require.NoError(t, err, clues.ToCore(err)) - newPrefixPathEmail, err := path.ServicePrefix(testTenant, migratedUser, path.ExchangeService, path.EmailCategory) + newPrefixPathEmail, err := path.BuildPrefix(testTenant, migratedUser, path.ExchangeService, path.EmailCategory) require.NoError(t, err, clues.ToCore(err)) - oldPrefixPathCont, err := path.ServicePrefix(testTenant, testUser, path.ExchangeService, path.ContactsCategory) + oldPrefixPathCont, err := path.BuildPrefix(testTenant, testUser, path.ExchangeService, path.ContactsCategory) require.NoError(t, err, clues.ToCore(err)) - newPrefixPathCont, err := path.ServicePrefix(testTenant, migratedUser, path.ExchangeService, path.ContactsCategory) + newPrefixPathCont, err := path.BuildPrefix(testTenant, migratedUser, path.ExchangeService, path.ContactsCategory) require.NoError(t, err, clues.ToCore(err)) var ( diff --git a/src/internal/m365/graph/collections.go b/src/internal/m365/graph/collections.go index 5e4ab22ea..c96673053 100644 --- a/src/internal/m365/graph/collections.go +++ b/src/internal/m365/graph/collections.go @@ -79,7 +79,7 @@ func BaseCollections( for cat := range categories { ictx := clues.Add(ctx, "base_service", service, "base_category", cat) - full, err := path.ServicePrefix(tenant, rOwner, service, cat) + full, err := path.BuildPrefix(tenant, rOwner, service, cat) if err != nil { // Shouldn't happen. err = clues.Wrap(err, "making path").WithClues(ictx) diff --git a/src/internal/m365/graph/collections_test.go b/src/internal/m365/graph/collections_test.go index a01064bae..1b075f30c 100644 --- a/src/internal/m365/graph/collections_test.go +++ b/src/internal/m365/graph/collections_test.go @@ -24,10 +24,10 @@ func (suite *CollectionsUnitSuite) TestNewPrefixCollection() { serv := path.OneDriveService cat := path.FilesCategory - p1, err := path.ServicePrefix("t", "ro1", serv, cat) + p1, err := path.BuildPrefix("t", "ro1", serv, cat) require.NoError(t, err, clues.ToCore(err)) - p2, err := path.ServicePrefix("t", "ro2", serv, cat) + p2, err := path.BuildPrefix("t", "ro2", serv, cat) require.NoError(t, err, clues.ToCore(err)) items, err := path.Build("t", "ro", serv, cat, true, "fld", "itm") diff --git a/src/internal/m365/service/onedrive/backup.go b/src/internal/m365/service/onedrive/backup.go index 169aba08c..c369afe11 100644 --- a/src/internal/m365/service/onedrive/backup.go +++ b/src/internal/m365/service/onedrive/backup.go @@ -109,7 +109,7 @@ func migrationCollections( // unlike exchange, which enumerates all folders on every // backup, onedrive needs to force the owner PN -> ID migration - mc, err := path.ServicePrefix( + mc, err := path.BuildPrefix( tenant, bpc.ProtectedResource.ID(), path.OneDriveService, @@ -118,7 +118,7 @@ func migrationCollections( return nil, clues.Wrap(err, "creating user id migration path") } - mpc, err := path.ServicePrefix( + mpc, err := path.BuildPrefix( tenant, bpc.ProtectedResource.Name(), path.OneDriveService, diff --git a/src/internal/operations/pathtransformer/restore_path_transformer.go b/src/internal/operations/pathtransformer/restore_path_transformer.go index 0225d81ea..5dbb9bba3 100644 --- a/src/internal/operations/pathtransformer/restore_path_transformer.go +++ b/src/internal/operations/pathtransformer/restore_path_transformer.go @@ -42,7 +42,7 @@ func locationRef( func basicLocationPath(repoRef path.Path, locRef *path.Builder) (path.Path, error) { if len(locRef.Elements()) == 0 { - res, err := path.ServicePrefix( + res, err := path.BuildPrefix( repoRef.Tenant(), repoRef.ResourceOwner(), repoRef.Service(), diff --git a/src/internal/operations/test/exchange_test.go b/src/internal/operations/test/exchange_test.go index 7fc1ff58e..226b8265c 100644 --- a/src/internal/operations/test/exchange_test.go +++ b/src/internal/operations/test/exchange_test.go @@ -347,7 +347,7 @@ func testExchangeContinuousBackups(suite *ExchangeBackupIntgSuite, toggles contr // }, } - rrPfx, err := path.ServicePrefix(acct.ID(), uidn.ID(), service, path.EmailCategory) + rrPfx, err := path.BuildPrefix(acct.ID(), uidn.ID(), service, path.EmailCategory) require.NoError(t, err, clues.ToCore(err)) // strip the category from the prefix; we primarily want the tenant and resource owner. diff --git a/src/internal/operations/test/onedrive_test.go b/src/internal/operations/test/onedrive_test.go index c4faedec6..9646b4ec9 100644 --- a/src/internal/operations/test/onedrive_test.go +++ b/src/internal/operations/test/onedrive_test.go @@ -213,7 +213,7 @@ func runDriveIncrementalTest( } ) - rrPfx, err := path.ServicePrefix(atid, roidn.ID(), service, category) + rrPfx, err := path.BuildPrefix(atid, roidn.ID(), service, category) require.NoError(t, err, clues.ToCore(err)) // strip the category from the prefix; we primarily want the tenant and resource owner. diff --git a/src/pkg/path/builder.go b/src/pkg/path/builder.go new file mode 100644 index 000000000..1cf502079 --- /dev/null +++ b/src/pkg/path/builder.go @@ -0,0 +1,379 @@ +package path + +import ( + "bytes" + "crypto/sha256" + "fmt" + + "github.com/alcionai/clues" +) + +// interface compliance required for handling PII +var ( + _ clues.Concealer = &Builder{} + _ fmt.Stringer = &Builder{} +) + +// Builder is a simple path representation that only tracks path elements. It +// can join, escape, and unescape elements. Higher-level packages are expected +// to wrap this struct to build resource-specific contexts (e.x. an +// ExchangeMailPath). +// Resource-specific paths allow access to more information like segments in the +// path. Builders that are turned into resource paths later on do not need to +// manually add prefixes for items that normally appear in the data layer (ex. +// tenant ID, service, user ID, etc). +type Builder struct { + // Unescaped version of elements. + elements Elements +} + +// Append creates a copy of this Builder and adds the given elements them to the +// end of the new Builder. Elements are added in the order they are passed. +func (pb Builder) Append(elements ...string) *Builder { + res := &Builder{elements: make([]string, len(pb.elements))} + copy(res.elements, pb.elements) + + // Unescaped elements can't fail validation. + //nolint:errcheck + res.appendElements(false, elements) + + return res +} + +func (pb *Builder) appendElements(escaped bool, elements []string) error { + for _, e := range elements { + if len(e) == 0 { + continue + } + + tmp := e + + if escaped { + tmp = TrimTrailingSlash(tmp) + // If tmp was just the path separator then it will be empty now. + if len(tmp) == 0 { + continue + } + + if err := validateEscapedElement(tmp); err != nil { + return err + } + + tmp = unescape(tmp) + } + + pb.elements = append(pb.elements, tmp) + } + + return nil +} + +// UnescapeAndAppend creates a copy of this Builder and adds one or more already +// escaped path elements to the end of the new Builder. Elements are added in +// the order they are passed. +func (pb Builder) UnescapeAndAppend(elements ...string) (*Builder, error) { + res := &Builder{elements: make([]string, 0, len(pb.elements))} + copy(res.elements, pb.elements) + + if err := res.appendElements(true, elements); err != nil { + return nil, err + } + + return res, nil +} + +// SplitUnescapeAppend takes in an escaped string representing a directory +// path, splits the string, and appends it to the current builder. +func (pb Builder) SplitUnescapeAppend(s string) (*Builder, error) { + elems := Split(TrimTrailingSlash(s)) + + return pb.UnescapeAndAppend(elems...) +} + +func (pb Builder) PopFront() *Builder { + if len(pb.elements) <= 1 { + return &Builder{} + } + + elements := make([]string, len(pb.elements)-1) + copy(elements, pb.elements[1:]) + + return &Builder{ + elements: elements, + } +} + +// Dir removes the last element from the builder. +func (pb Builder) Dir() *Builder { + if len(pb.elements) <= 1 { + return &Builder{} + } + + return &Builder{ + // Safe to use the same elements because Builders are immutable. + elements: pb.elements[:len(pb.elements)-1], + } +} + +// HeadElem returns the first element in the Builder. +func (pb Builder) HeadElem() string { + if len(pb.elements) == 0 { + return "" + } + + return pb.elements[0] +} + +// LastElem returns the last element in the Builder. +func (pb Builder) LastElem() string { + if len(pb.elements) == 0 { + return "" + } + + return pb.elements[len(pb.elements)-1] +} + +// UpdateParent updates leading elements matching prev to be cur and returns +// true if it was updated. If prev is not a prefix of this Builder changes +// nothing and returns false. If either prev or cur is nil does nothing and +// returns false. +func (pb *Builder) UpdateParent(prev, cur *Builder) bool { + if prev == cur || prev == nil || cur == nil || len(prev.Elements()) > len(pb.Elements()) { + return false + } + + parent := true + + for i, e := range prev.Elements() { + if pb.elements[i] != e { + parent = false + break + } + } + + if !parent { + return false + } + + pb.elements = append(cur.Elements(), pb.elements[len(prev.Elements()):]...) + + return true +} + +// ShortRef produces a truncated hash of the builder that +// acts as a unique identifier. +func (pb Builder) ShortRef() string { + if len(pb.elements) == 0 { + return "" + } + + data := bytes.Buffer{} + + for _, element := range pb.elements { + data.WriteString(element) + } + + sum := sha256.Sum256(data.Bytes()) + + // Some conversions to get the right number of characters in the output. This + // outputs hex, so we need to take the target number of characters and do the + // equivalent of (shortRefCharacters * 4) / 8. This is + // / which gets us how many bytes + // to give to our format command. + numBytes := shortRefCharacters / 2 + + return fmt.Sprintf("%x", sum[:numBytes]) +} + +// Elements returns all the elements in the path. This is a temporary function +// and will likely be updated to handle encoded elements instead of clear-text +// elements in the future. +func (pb Builder) Elements() Elements { + return append(Elements{}, pb.elements...) +} + +// withPrefix creates a Builder prefixed with the parameter values, and +// concatenated with the current builder elements. +func (pb Builder) withPrefix(elements ...string) *Builder { + res := Builder{}.Append(elements...) + res.elements = append(res.elements, pb.elements...) + + return res +} + +// verifyPrefix ensures that the tenant and resourceOwner are valid +// values, and that the builder has some directory structure. +func (pb Builder) verifyPrefix(tenant, resourceOwner string) error { + if err := verifyInputValues(tenant, resourceOwner); err != nil { + return err + } + + if len(pb.elements) == 0 { + return clues.New("missing path beyond prefix") + } + + return nil +} + +// --------------------------------------------------------------------------- +// Data Layer Path Transformers +// --------------------------------------------------------------------------- + +func (pb Builder) ToStreamStorePath( + tenant, purpose string, + service ServiceType, + isItem bool, +) (Path, error) { + if err := verifyInputValues(tenant, purpose); err != nil { + return nil, err + } + + if isItem && len(pb.elements) == 0 { + return nil, clues.New("missing path beyond prefix") + } + + metadataService := UnknownService + + switch service { + case ExchangeService: + metadataService = ExchangeMetadataService + case OneDriveService: + metadataService = OneDriveMetadataService + case SharePointService: + metadataService = SharePointMetadataService + } + + return &dataLayerResourcePath{ + Builder: *pb.withPrefix( + tenant, + metadataService.String(), + purpose, + DetailsCategory.String()), + service: metadataService, + category: DetailsCategory, + hasItem: isItem, + }, nil +} + +func (pb Builder) ToServiceCategoryMetadataPath( + tenant, user string, + service ServiceType, + category CategoryType, + isItem bool, +) (Path, error) { + if err := ValidateServiceAndCategory(service, category); err != nil { + return nil, err + } + + if err := verifyInputValues(tenant, user); err != nil { + return nil, err + } + + if isItem && len(pb.elements) == 0 { + return nil, clues.New("missing path beyond prefix") + } + + metadataService := UnknownService + + switch service { + case ExchangeService: + metadataService = ExchangeMetadataService + case OneDriveService: + metadataService = OneDriveMetadataService + case SharePointService: + metadataService = SharePointMetadataService + } + + return &dataLayerResourcePath{ + Builder: *pb.withPrefix( + tenant, + metadataService.String(), + user, + category.String(), + ), + service: metadataService, + category: category, + hasItem: isItem, + }, nil +} + +func (pb Builder) ToDataLayerPath( + tenant, user string, + service ServiceType, + category CategoryType, + isItem bool, +) (Path, error) { + if err := ValidateServiceAndCategory(service, category); err != nil { + return nil, err + } + + if err := pb.verifyPrefix(tenant, user); err != nil { + return nil, err + } + + return &dataLayerResourcePath{ + Builder: *pb.withPrefix( + tenant, + service.String(), + user, + category.String()), + service: service, + category: category, + hasItem: isItem, + }, nil +} + +func (pb Builder) ToDataLayerExchangePathForCategory( + tenant, user string, + category CategoryType, + isItem bool, +) (Path, error) { + return pb.ToDataLayerPath(tenant, user, ExchangeService, category, isItem) +} + +func (pb Builder) ToDataLayerOneDrivePath( + tenant, user string, + isItem bool, +) (Path, error) { + return pb.ToDataLayerPath(tenant, user, OneDriveService, FilesCategory, isItem) +} + +func (pb Builder) ToDataLayerSharePointPath( + tenant, site string, + category CategoryType, + isItem bool, +) (Path, error) { + return pb.ToDataLayerPath(tenant, site, SharePointService, category, isItem) +} + +// --------------------------------------------------------------------------- +// Stringers and PII Concealer Compliance +// --------------------------------------------------------------------------- + +// Conceal produces a concealed representation of the builder, suitable for +// logging, storing in errors, and other output. +func (pb Builder) Conceal() string { + return pb.elements.Conceal() +} + +// Format produces a concealed representation of the builder, even when +// used within a PrintF, suitable for logging, storing in errors, +// and other output. +func (pb Builder) Format(fs fmt.State, _ rune) { + fmt.Fprint(fs, pb.Conceal()) +} + +// String returns a string that contains all path elements joined together. +// Elements of the path that need escaping are escaped. +// The result is not concealed, and is not suitable for logging or structured +// errors. +func (pb Builder) String() string { + return pb.elements.String() +} + +// PlainString returns an unescaped, unmodified string of the builder. +// The result is not concealed, and is not suitable for logging or structured +// errors. +func (pb Builder) PlainString() string { + return pb.elements.PlainString() +} diff --git a/src/pkg/path/builder_test.go b/src/pkg/path/builder_test.go new file mode 100644 index 000000000..cb483606d --- /dev/null +++ b/src/pkg/path/builder_test.go @@ -0,0 +1,369 @@ +package path + +import ( + "fmt" + "strings" + "testing" + + "github.com/alcionai/clues" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/tester" +) + +type BuilderUnitSuite struct { + tester.Suite +} + +func TestBuilderUnitSuite(t *testing.T) { + suite.Run(t, &BuilderUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +// set the clues hashing to mask for the span of this suite +func (suite *BuilderUnitSuite) SetupSuite() { + clues.SetHasher(clues.HashCfg{HashAlg: clues.Flatmask}) +} + +// revert clues hashing to plaintext for all other tests +func (suite *BuilderUnitSuite) TeardownSuite() { + clues.SetHasher(clues.NoHash()) +} + +func (suite *BuilderUnitSuite) TestAppend() { + table := append(append([]testData{}, genericCases...), basicUnescapedInputs...) + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + p := Builder{}.Append(test.input...) + assert.Equal(t, test.expectedString, p.String()) + }) + } +} + +func (suite *BuilderUnitSuite) TestAppendItem() { + t := suite.T() + + p, err := Build("t", "ro", ExchangeService, EmailCategory, false, "foo", "bar") + require.NoError(t, err, clues.ToCore(err)) + + pb := p.ToBuilder() + assert.Equal(t, pb.String(), p.String()) + + pb = pb.Append("qux") + + p, err = p.AppendItem("qux") + + require.NoError(t, err, clues.ToCore(err)) + assert.Equal(t, pb.String(), p.String()) + + _, err = p.AppendItem("fnords") + require.Error(t, err, clues.ToCore(err)) +} + +func (suite *BuilderUnitSuite) TestUnescapeAndAppend() { + table := append(append([]testData{}, genericCases...), basicEscapedInputs...) + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + p, err := Builder{}.UnescapeAndAppend(test.input...) + require.NoError(t, err, clues.ToCore(err)) + + assert.Equal(t, test.expectedString, p.String()) + }) + } +} + +func (suite *BuilderUnitSuite) TestEscapedFailure() { + target := "i_s" + + for c := range charactersToEscape { + suite.Run(fmt.Sprintf("Unescaped-%c", c), func() { + tmp := strings.ReplaceAll(target, "_", string(c)) + + _, err := Builder{}.UnescapeAndAppend("this", tmp, "path") + assert.Errorf(suite.T(), err, "path with unescaped %s did not error", string(c)) + }) + } +} + +func (suite *BuilderUnitSuite) TestBadEscapeSequenceErrors() { + target := `i\_s/a` + notEscapes := []rune{'a', 'b', '#', '%'} + + for _, c := range notEscapes { + suite.Run(fmt.Sprintf("Escaped-%c", c), func() { + tmp := strings.ReplaceAll(target, "_", string(c)) + + _, err := Builder{}.UnescapeAndAppend("this", tmp, "path") + assert.Errorf( + suite.T(), + err, + "path with bad escape sequence %c%c did not error", + escapeCharacter, + c) + }) + } +} + +func (suite *BuilderUnitSuite) TestTrailingEscapeChar() { + base := []string{"this", "is", "a", "path"} + + for i := 0; i < len(base); i++ { + suite.Run(fmt.Sprintf("Element%v", i), func() { + path := make([]string, len(base)) + copy(path, base) + path[i] = path[i] + string(escapeCharacter) + + _, err := Builder{}.UnescapeAndAppend(path...) + assert.Error( + suite.T(), + err, + "path with trailing escape character did not error") + }) + } +} + +func (suite *BuilderUnitSuite) TestElements() { + table := []struct { + name string + input []string + output []string + pathFunc func(elements []string) (*Builder, error) + }{ + { + name: "SimpleEscapedPath", + input: []string{"this", "is", "a", "path"}, + output: []string{"this", "is", "a", "path"}, + pathFunc: func(elements []string) (*Builder, error) { + return Builder{}.UnescapeAndAppend(elements...) + }, + }, + { + name: "SimpleUnescapedPath", + input: []string{"this", "is", "a", "path"}, + output: []string{"this", "is", "a", "path"}, + pathFunc: func(elements []string) (*Builder, error) { + return Builder{}.Append(elements...), nil + }, + }, + { + name: "EscapedPath", + input: []string{"this", `is\/`, "a", "path"}, + output: []string{"this", "is/", "a", "path"}, + pathFunc: func(elements []string) (*Builder, error) { + return Builder{}.UnescapeAndAppend(elements...) + }, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + p, err := test.pathFunc(test.input) + require.NoError(t, err, clues.ToCore(err)) + + assert.Equal(t, Elements(test.output), p.Elements()) + }) + } +} + +func (suite *BuilderUnitSuite) TestPopFront() { + table := []struct { + name string + base *Builder + expectedString string + }{ + { + name: "Empty", + base: &Builder{}, + expectedString: "", + }, + { + name: "OneElement", + base: Builder{}.Append("something"), + expectedString: "", + }, + { + name: "TwoElements", + base: Builder{}.Append("something", "else"), + expectedString: "else", + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + assert.Equal(t, test.expectedString, test.base.PopFront().String()) + }) + } +} + +func (suite *BuilderUnitSuite) TestShortRef() { + table := []struct { + name string + inputElements []string + expectedLen int + }{ + { + name: "PopulatedPath", + inputElements: []string{"this", "is", "a", "path"}, + expectedLen: shortRefCharacters, + }, + { + name: "EmptyPath", + inputElements: nil, + expectedLen: 0, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + pb := Builder{}.Append(test.inputElements...) + ref := pb.ShortRef() + assert.Len(suite.T(), ref, test.expectedLen) + }) + } +} + +func (suite *BuilderUnitSuite) TestShortRefIsStable() { + t := suite.T() + pb := Builder{}.Append("this", "is", "a", "path") + prevRef := pb.ShortRef() + assert.Len(t, prevRef, shortRefCharacters) + + for i := 0; i < 5; i++ { + ref := pb.ShortRef() + assert.Len(t, ref, shortRefCharacters) + assert.Equal(t, prevRef, ref, "ShortRef changed between calls") + + prevRef = ref + } +} + +func (suite *BuilderUnitSuite) TestShortRefIsUnique() { + pb1 := Builder{}.Append("this", "is", "a", "path") + pb2 := pb1.Append("also") + + require.NotEqual(suite.T(), pb1, pb2) + assert.NotEqual(suite.T(), pb1.ShortRef(), pb2.ShortRef()) +} + +// TestShortRefUniqueWithEscaping tests that two paths that output the same +// unescaped string but different escaped strings have different shortrefs. This +// situation can occur when one path has embedded path separators while the +// other does not but contains the same characters. +func (suite *BuilderUnitSuite) TestShortRefUniqueWithEscaping() { + pb1 := Builder{}.Append(`this`, `is`, `a`, `path`) + pb2 := Builder{}.Append(`this`, `is/a`, `path`) + + require.NotEqual(suite.T(), pb1, pb2) + assert.NotEqual(suite.T(), pb1.ShortRef(), pb2.ShortRef()) +} + +func (suite *BuilderUnitSuite) TestFolder() { + table := []struct { + name string + p func(t *testing.T) Path + escape bool + expectFolder string + expectSplit []string + }{ + { + name: "clean path", + p: func(t *testing.T) Path { + p, err := Builder{}. + Append("a", "b", "c"). + ToDataLayerExchangePathForCategory("t", "u", EmailCategory, false) + require.NoError(t, err, clues.ToCore(err)) + + return p + }, + expectFolder: "a/b/c", + expectSplit: []string{"a", "b", "c"}, + }, + { + name: "clean path escaped", + p: func(t *testing.T) Path { + p, err := Builder{}. + Append("a", "b", "c"). + ToDataLayerExchangePathForCategory("t", "u", EmailCategory, false) + require.NoError(t, err, clues.ToCore(err)) + + return p + }, + escape: true, + expectFolder: "a/b/c", + expectSplit: []string{"a", "b", "c"}, + }, + { + name: "escapable path", + p: func(t *testing.T) Path { + p, err := Builder{}. + Append("a/", "b", "c"). + ToDataLayerExchangePathForCategory("t", "u", EmailCategory, false) + require.NoError(t, err, clues.ToCore(err)) + + return p + }, + expectFolder: "a//b/c", + expectSplit: []string{"a", "b", "c"}, + }, + { + name: "escapable path escaped", + p: func(t *testing.T) Path { + p, err := Builder{}. + Append("a/", "b", "c"). + ToDataLayerExchangePathForCategory("t", "u", EmailCategory, false) + require.NoError(t, err, clues.ToCore(err)) + + return p + }, + escape: true, + expectFolder: "a\\//b/c", + expectSplit: []string{"a\\/", "b", "c"}, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + p := test.p(t) + result := p.Folder(test.escape) + assert.Equal(t, test.expectFolder, result) + assert.Equal(t, test.expectSplit, Split(result)) + }) + } +} + +func (suite *BuilderUnitSuite) TestPIIHandling() { + p, err := Build("t", "ro", ExchangeService, EventsCategory, true, "dir", "item") + require.NoError(suite.T(), err) + + table := []struct { + name string + p Path + expect string + expectPlain string + }{ + { + name: "standard path", + p: p, + expect: "***/exchange/***/events/***/***", + expectPlain: "t/exchange/ro/events/dir/item", + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + assert.Equal(t, test.expect, test.p.Conceal(), "conceal") + assert.Equal(t, test.expectPlain, test.p.String(), "string") + assert.Equal(t, test.expect, fmt.Sprintf("%s", test.p), "fmt %%s") + assert.Equal(t, test.expect, fmt.Sprintf("%+v", test.p), "fmt %%+v") + assert.Equal(t, test.expectPlain, test.p.PlainString(), "plain") + }) + } +} diff --git a/src/pkg/path/category_type.go b/src/pkg/path/category_type.go new file mode 100644 index 000000000..4a992176f --- /dev/null +++ b/src/pkg/path/category_type.go @@ -0,0 +1,108 @@ +package path + +import ( + "fmt" + "strings" + + "github.com/alcionai/clues" +) + +var ErrorUnknownCategory = clues.New("unknown category string") + +// CategoryType denotes what category of data the path corresponds to. The order +// of the enums below can be changed, but the string representation of each enum +// must remain the same or migration code needs to be added to handle changes to +// the string format. +type CategoryType int + +//go:generate stringer -type=CategoryType -linecomment +const ( + UnknownCategory CategoryType = iota + EmailCategory // email + ContactsCategory // contacts + EventsCategory // events + FilesCategory // files + ListsCategory // lists + LibrariesCategory // libraries + PagesCategory // pages + DetailsCategory // details +) + +func ToCategoryType(category string) CategoryType { + cat := strings.ToLower(category) + + switch cat { + case strings.ToLower(EmailCategory.String()): + return EmailCategory + case strings.ToLower(ContactsCategory.String()): + return ContactsCategory + case strings.ToLower(EventsCategory.String()): + return EventsCategory + case strings.ToLower(FilesCategory.String()): + return FilesCategory + case strings.ToLower(LibrariesCategory.String()): + return LibrariesCategory + case strings.ToLower(ListsCategory.String()): + return ListsCategory + case strings.ToLower(PagesCategory.String()): + return PagesCategory + case strings.ToLower(DetailsCategory.String()): + return DetailsCategory + default: + return UnknownCategory + } +} + +// --------------------------------------------------------------------------- +// Service-Category pairings +// --------------------------------------------------------------------------- + +// serviceCategories is a mapping of all valid service/category pairs for +// non-metadata paths. +var serviceCategories = map[ServiceType]map[CategoryType]struct{}{ + ExchangeService: { + EmailCategory: {}, + ContactsCategory: {}, + EventsCategory: {}, + }, + OneDriveService: { + FilesCategory: {}, + }, + SharePointService: { + LibrariesCategory: {}, + ListsCategory: {}, + PagesCategory: {}, + }, +} + +func validateServiceAndCategoryStrings(s, c string) (ServiceType, CategoryType, error) { + service := toServiceType(s) + if service == UnknownService { + return UnknownService, UnknownCategory, clues.Stack(ErrorUnknownService).With("service", fmt.Sprintf("%q", s)) + } + + category := ToCategoryType(c) + if category == UnknownCategory { + return UnknownService, UnknownCategory, clues.Stack(ErrorUnknownService).With("category", fmt.Sprintf("%q", c)) + } + + if err := ValidateServiceAndCategory(service, category); err != nil { + return UnknownService, UnknownCategory, err + } + + return service, category, nil +} + +func ValidateServiceAndCategory(service ServiceType, category CategoryType) error { + cats, ok := serviceCategories[service] + if !ok { + return clues.New("unsupported service").With("service", fmt.Sprintf("%q", service)) + } + + if _, ok := cats[category]; !ok { + return clues.New("unknown service/category combination"). + With("service", fmt.Sprintf("%q", service), "category", fmt.Sprintf("%q", category)) + } + + return nil +} diff --git a/src/pkg/path/drive.go b/src/pkg/path/drive.go index a8d92f487..c5427e5bc 100644 --- a/src/pkg/path/drive.go +++ b/src/pkg/path/drive.go @@ -2,6 +2,7 @@ package path import "github.com/alcionai/clues" +// TODO: Move this into m365/collection/drive // drivePath is used to represent path components // of an item within the drive i.e. // Given `drives/b!X_8Z2zuXpkKkXZsr7gThk9oJpuj0yXVGnK5_VjRRPK-q725SX_8ZQJgFDK8PlFxA/root:/Folder1/Folder2/file` diff --git a/src/pkg/path/path.go b/src/pkg/path/path.go index b7cd38da0..8f8463b42 100644 --- a/src/pkg/path/path.go +++ b/src/pkg/path/path.go @@ -51,8 +51,6 @@ package path import ( - "bytes" - "crypto/sha256" "fmt" "strings" @@ -122,12 +120,6 @@ type Path interface { fmt.Stringer } -// interface compliance required for handling PII -var ( - _ clues.Concealer = &Builder{} - _ fmt.Stringer = &Builder{} -) - // RestorePaths denotes the location to find an item in kopia and the path of // the collection to place the item in for restore. type RestorePaths struct { @@ -135,185 +127,26 @@ type RestorePaths struct { RestorePath Path } -// Builder is a simple path representation that only tracks path elements. It -// can join, escape, and unescape elements. Higher-level packages are expected -// to wrap this struct to build resource-specific contexts (e.x. an -// ExchangeMailPath). -// Resource-specific paths allow access to more information like segments in the -// path. Builders that are turned into resource paths later on do not need to -// manually add prefixes for items that normally appear in the data layer (ex. -// tenant ID, service, user ID, etc). -type Builder struct { - // Unescaped version of elements. - elements Elements +// --------------------------------------------------------------------------- +// Exported Helpers +// --------------------------------------------------------------------------- + +func Build( + tenant, resourceOwner string, + service ServiceType, + category CategoryType, + hasItem bool, + elements ...string, +) (Path, error) { + b := Builder{}.Append(elements...) + + return b.ToDataLayerPath( + tenant, resourceOwner, + service, category, + hasItem) } -// Append creates a copy of this Builder and adds the given elements them to the -// end of the new Builder. Elements are added in the order they are passed. -func (pb Builder) Append(elements ...string) *Builder { - res := &Builder{elements: make([]string, len(pb.elements))} - copy(res.elements, pb.elements) - - // Unescaped elements can't fail validation. - //nolint:errcheck - res.appendElements(false, elements) - - return res -} - -func (pb *Builder) appendElements(escaped bool, elements []string) error { - for _, e := range elements { - if len(e) == 0 { - continue - } - - tmp := e - - if escaped { - tmp = TrimTrailingSlash(tmp) - // If tmp was just the path separator then it will be empty now. - if len(tmp) == 0 { - continue - } - - if err := validateEscapedElement(tmp); err != nil { - return err - } - - tmp = unescape(tmp) - } - - pb.elements = append(pb.elements, tmp) - } - - return nil -} - -// UnescapeAndAppend creates a copy of this Builder and adds one or more already -// escaped path elements to the end of the new Builder. Elements are added in -// the order they are passed. -func (pb Builder) UnescapeAndAppend(elements ...string) (*Builder, error) { - res := &Builder{elements: make([]string, 0, len(pb.elements))} - copy(res.elements, pb.elements) - - if err := res.appendElements(true, elements); err != nil { - return nil, err - } - - return res, nil -} - -// SplitUnescapeAppend takes in an escaped string representing a directory -// path, splits the string, and appends it to the current builder. -func (pb Builder) SplitUnescapeAppend(s string) (*Builder, error) { - elems := Split(TrimTrailingSlash(s)) - - return pb.UnescapeAndAppend(elems...) -} - -func (pb Builder) PopFront() *Builder { - if len(pb.elements) <= 1 { - return &Builder{} - } - - elements := make([]string, len(pb.elements)-1) - copy(elements, pb.elements[1:]) - - return &Builder{ - elements: elements, - } -} - -// Dir removes the last element from the builder. -func (pb Builder) Dir() *Builder { - if len(pb.elements) <= 1 { - return &Builder{} - } - - return &Builder{ - // Safe to use the same elements because Builders are immutable. - elements: pb.elements[:len(pb.elements)-1], - } -} - -// HeadElem returns the first element in the Builder. -func (pb Builder) HeadElem() string { - if len(pb.elements) == 0 { - return "" - } - - return pb.elements[0] -} - -// LastElem returns the last element in the Builder. -func (pb Builder) LastElem() string { - if len(pb.elements) == 0 { - return "" - } - - return pb.elements[len(pb.elements)-1] -} - -// UpdateParent updates leading elements matching prev to be cur and returns -// true if it was updated. If prev is not a prefix of this Builder changes -// nothing and returns false. If either prev or cur is nil does nothing and -// returns false. -func (pb *Builder) UpdateParent(prev, cur *Builder) bool { - if prev == cur || prev == nil || cur == nil || len(prev.Elements()) > len(pb.Elements()) { - return false - } - - parent := true - - for i, e := range prev.Elements() { - if pb.elements[i] != e { - parent = false - break - } - } - - if !parent { - return false - } - - pb.elements = append(cur.Elements(), pb.elements[len(prev.Elements()):]...) - - return true -} - -// ShortRef produces a truncated hash of the builder that -// acts as a unique identifier. -func (pb Builder) ShortRef() string { - if len(pb.elements) == 0 { - return "" - } - - data := bytes.Buffer{} - - for _, element := range pb.elements { - data.WriteString(element) - } - - sum := sha256.Sum256(data.Bytes()) - - // Some conversions to get the right number of characters in the output. This - // outputs hex, so we need to take the target number of characters and do the - // equivalent of (shortRefCharacters * 4) / 8. This is - // / which gets us how many bytes - // to give to our format command. - numBytes := shortRefCharacters / 2 - - return fmt.Sprintf("%x", sum[:numBytes]) -} - -// Elements returns all the elements in the path. This is a temporary function -// and will likely be updated to handle encoded elements instead of clear-text -// elements in the future. -func (pb Builder) Elements() Elements { - return append(Elements{}, pb.elements...) -} - -func ServicePrefix( +func BuildPrefix( tenant, resourceOwner string, s ServiceType, c CategoryType, @@ -336,197 +169,6 @@ func ServicePrefix( }, nil } -// withPrefix creates a Builder prefixed with the parameter values, and -// concatenated with the current builder elements. -func (pb Builder) withPrefix(elements ...string) *Builder { - res := Builder{}.Append(elements...) - res.elements = append(res.elements, pb.elements...) - - return res -} - -// --------------------------------------------------------------------------- -// Data Layer Path Transformers -// --------------------------------------------------------------------------- - -func (pb Builder) ToStreamStorePath( - tenant, purpose string, - service ServiceType, - isItem bool, -) (Path, error) { - if err := verifyInputValues(tenant, purpose); err != nil { - return nil, err - } - - if isItem && len(pb.elements) == 0 { - return nil, clues.New("missing path beyond prefix") - } - - metadataService := UnknownService - - switch service { - case ExchangeService: - metadataService = ExchangeMetadataService - case OneDriveService: - metadataService = OneDriveMetadataService - case SharePointService: - metadataService = SharePointMetadataService - } - - return &dataLayerResourcePath{ - Builder: *pb.withPrefix( - tenant, - metadataService.String(), - purpose, - DetailsCategory.String()), - service: metadataService, - category: DetailsCategory, - hasItem: isItem, - }, nil -} - -func (pb Builder) ToServiceCategoryMetadataPath( - tenant, user string, - service ServiceType, - category CategoryType, - isItem bool, -) (Path, error) { - if err := ValidateServiceAndCategory(service, category); err != nil { - return nil, err - } - - if err := verifyInputValues(tenant, user); err != nil { - return nil, err - } - - if isItem && len(pb.elements) == 0 { - return nil, clues.New("missing path beyond prefix") - } - - metadataService := UnknownService - - switch service { - case ExchangeService: - metadataService = ExchangeMetadataService - case OneDriveService: - metadataService = OneDriveMetadataService - case SharePointService: - metadataService = SharePointMetadataService - } - - return &dataLayerResourcePath{ - Builder: *pb.withPrefix( - tenant, - metadataService.String(), - user, - category.String(), - ), - service: metadataService, - category: category, - hasItem: isItem, - }, nil -} - -func (pb Builder) ToDataLayerPath( - tenant, user string, - service ServiceType, - category CategoryType, - isItem bool, -) (Path, error) { - if err := ValidateServiceAndCategory(service, category); err != nil { - return nil, err - } - - if err := pb.verifyPrefix(tenant, user); err != nil { - return nil, err - } - - return &dataLayerResourcePath{ - Builder: *pb.withPrefix( - tenant, - service.String(), - user, - category.String()), - service: service, - category: category, - hasItem: isItem, - }, nil -} - -func (pb Builder) ToDataLayerExchangePathForCategory( - tenant, user string, - category CategoryType, - isItem bool, -) (Path, error) { - return pb.ToDataLayerPath(tenant, user, ExchangeService, category, isItem) -} - -func (pb Builder) ToDataLayerOneDrivePath( - tenant, user string, - isItem bool, -) (Path, error) { - return pb.ToDataLayerPath(tenant, user, OneDriveService, FilesCategory, isItem) -} - -func (pb Builder) ToDataLayerSharePointPath( - tenant, site string, - category CategoryType, - isItem bool, -) (Path, error) { - return pb.ToDataLayerPath(tenant, site, SharePointService, category, isItem) -} - -// --------------------------------------------------------------------------- -// Stringers and PII Concealer Compliance -// --------------------------------------------------------------------------- - -// Conceal produces a concealed representation of the builder, suitable for -// logging, storing in errors, and other output. -func (pb Builder) Conceal() string { - return pb.elements.Conceal() -} - -// Format produces a concealed representation of the builder, even when -// used within a PrintF, suitable for logging, storing in errors, -// and other output. -func (pb Builder) Format(fs fmt.State, _ rune) { - fmt.Fprint(fs, pb.Conceal()) -} - -// String returns a string that contains all path elements joined together. -// Elements of the path that need escaping are escaped. -// The result is not concealed, and is not suitable for logging or structured -// errors. -func (pb Builder) String() string { - return pb.elements.String() -} - -// PlainString returns an unescaped, unmodified string of the builder. -// The result is not concealed, and is not suitable for logging or structured -// errors. -func (pb Builder) PlainString() string { - return pb.elements.PlainString() -} - -// --------------------------------------------------------------------------- -// Exported Helpers -// --------------------------------------------------------------------------- - -func Build( - tenant, resourceOwner string, - service ServiceType, - category CategoryType, - hasItem bool, - elements ...string, -) (Path, error) { - b := Builder{}.Append(elements...) - - return b.ToDataLayerPath( - tenant, resourceOwner, - service, category, - hasItem) -} - // FromDataLayerPath parses the escaped path p, validates the elements in p // match a resource-specific path format, and returns a Path struct for that // resource-specific type. If p does not match any resource-specific paths or @@ -762,17 +404,3 @@ func join(elements []string) string { // '\' according to the escaping rules. return strings.Join(elements, string(PathSeparator)) } - -// verifyPrefix ensures that the tenant and resourceOwner are valid -// values, and that the builder has some directory structure. -func (pb Builder) verifyPrefix(tenant, resourceOwner string) error { - if err := verifyInputValues(tenant, resourceOwner); err != nil { - return err - } - - if len(pb.elements) == 0 { - return clues.New("missing path beyond prefix") - } - - return nil -} diff --git a/src/pkg/path/path_test.go b/src/pkg/path/path_test.go index be43d3732..ca3055983 100644 --- a/src/pkg/path/path_test.go +++ b/src/pkg/path/path_test.go @@ -2,7 +2,6 @@ package path import ( "fmt" - "strings" "testing" "github.com/alcionai/clues" @@ -233,239 +232,7 @@ func (suite *PathUnitSuite) TeardownSuite() { clues.SetHasher(clues.NoHash()) } -func (suite *PathUnitSuite) TestAppend() { - table := append(append([]testData{}, genericCases...), basicUnescapedInputs...) - for _, test := range table { - suite.Run(test.name, func() { - t := suite.T() - - p := Builder{}.Append(test.input...) - assert.Equal(t, test.expectedString, p.String()) - }) - } -} - -func (suite *PathUnitSuite) TestAppendItem() { - t := suite.T() - - p, err := Build("t", "ro", ExchangeService, EmailCategory, false, "foo", "bar") - require.NoError(t, err, clues.ToCore(err)) - - pb := p.ToBuilder() - assert.Equal(t, pb.String(), p.String()) - - pb = pb.Append("qux") - - p, err = p.AppendItem("qux") - - require.NoError(t, err, clues.ToCore(err)) - assert.Equal(t, pb.String(), p.String()) - - _, err = p.AppendItem("fnords") - require.Error(t, err, clues.ToCore(err)) -} - -func (suite *PathUnitSuite) TestUnescapeAndAppend() { - table := append(append([]testData{}, genericCases...), basicEscapedInputs...) - for _, test := range table { - suite.Run(test.name, func() { - t := suite.T() - - p, err := Builder{}.UnescapeAndAppend(test.input...) - require.NoError(t, err, clues.ToCore(err)) - - assert.Equal(t, test.expectedString, p.String()) - }) - } -} - -func (suite *PathUnitSuite) TestEscapedFailure() { - target := "i_s" - - for c := range charactersToEscape { - suite.Run(fmt.Sprintf("Unescaped-%c", c), func() { - tmp := strings.ReplaceAll(target, "_", string(c)) - - _, err := Builder{}.UnescapeAndAppend("this", tmp, "path") - assert.Errorf(suite.T(), err, "path with unescaped %s did not error", string(c)) - }) - } -} - -func (suite *PathUnitSuite) TestBadEscapeSequenceErrors() { - target := `i\_s/a` - notEscapes := []rune{'a', 'b', '#', '%'} - - for _, c := range notEscapes { - suite.Run(fmt.Sprintf("Escaped-%c", c), func() { - tmp := strings.ReplaceAll(target, "_", string(c)) - - _, err := Builder{}.UnescapeAndAppend("this", tmp, "path") - assert.Errorf( - suite.T(), - err, - "path with bad escape sequence %c%c did not error", - escapeCharacter, - c) - }) - } -} - -func (suite *PathUnitSuite) TestTrailingEscapeChar() { - base := []string{"this", "is", "a", "path"} - - for i := 0; i < len(base); i++ { - suite.Run(fmt.Sprintf("Element%v", i), func() { - path := make([]string, len(base)) - copy(path, base) - path[i] = path[i] + string(escapeCharacter) - - _, err := Builder{}.UnescapeAndAppend(path...) - assert.Error( - suite.T(), - err, - "path with trailing escape character did not error") - }) - } -} - -func (suite *PathUnitSuite) TestElements() { - table := []struct { - name string - input []string - output []string - pathFunc func(elements []string) (*Builder, error) - }{ - { - name: "SimpleEscapedPath", - input: []string{"this", "is", "a", "path"}, - output: []string{"this", "is", "a", "path"}, - pathFunc: func(elements []string) (*Builder, error) { - return Builder{}.UnescapeAndAppend(elements...) - }, - }, - { - name: "SimpleUnescapedPath", - input: []string{"this", "is", "a", "path"}, - output: []string{"this", "is", "a", "path"}, - pathFunc: func(elements []string) (*Builder, error) { - return Builder{}.Append(elements...), nil - }, - }, - { - name: "EscapedPath", - input: []string{"this", `is\/`, "a", "path"}, - output: []string{"this", "is/", "a", "path"}, - pathFunc: func(elements []string) (*Builder, error) { - return Builder{}.UnescapeAndAppend(elements...) - }, - }, - } - for _, test := range table { - suite.Run(test.name, func() { - t := suite.T() - - p, err := test.pathFunc(test.input) - require.NoError(t, err, clues.ToCore(err)) - - assert.Equal(t, Elements(test.output), p.Elements()) - }) - } -} - -func (suite *PathUnitSuite) TestPopFront() { - table := []struct { - name string - base *Builder - expectedString string - }{ - { - name: "Empty", - base: &Builder{}, - expectedString: "", - }, - { - name: "OneElement", - base: Builder{}.Append("something"), - expectedString: "", - }, - { - name: "TwoElements", - base: Builder{}.Append("something", "else"), - expectedString: "else", - }, - } - for _, test := range table { - suite.Run(test.name, func() { - t := suite.T() - - assert.Equal(t, test.expectedString, test.base.PopFront().String()) - }) - } -} - -func (suite *PathUnitSuite) TestShortRef() { - table := []struct { - name string - inputElements []string - expectedLen int - }{ - { - name: "PopulatedPath", - inputElements: []string{"this", "is", "a", "path"}, - expectedLen: shortRefCharacters, - }, - { - name: "EmptyPath", - inputElements: nil, - expectedLen: 0, - }, - } - for _, test := range table { - suite.Run(test.name, func() { - pb := Builder{}.Append(test.inputElements...) - ref := pb.ShortRef() - assert.Len(suite.T(), ref, test.expectedLen) - }) - } -} - -func (suite *PathUnitSuite) TestShortRefIsStable() { - t := suite.T() - pb := Builder{}.Append("this", "is", "a", "path") - prevRef := pb.ShortRef() - assert.Len(t, prevRef, shortRefCharacters) - - for i := 0; i < 5; i++ { - ref := pb.ShortRef() - assert.Len(t, ref, shortRefCharacters) - assert.Equal(t, prevRef, ref, "ShortRef changed between calls") - - prevRef = ref - } -} - -func (suite *PathUnitSuite) TestShortRefIsUnique() { - pb1 := Builder{}.Append("this", "is", "a", "path") - pb2 := pb1.Append("also") - - require.NotEqual(suite.T(), pb1, pb2) - assert.NotEqual(suite.T(), pb1.ShortRef(), pb2.ShortRef()) -} - -// TestShortRefUniqueWithEscaping tests that two paths that output the same -// unescaped string but different escaped strings have different shortrefs. This -// situation can occur when one path has embedded path separators while the -// other does not but contains the same characters. -func (suite *PathUnitSuite) TestShortRefUniqueWithEscaping() { - pb1 := Builder{}.Append(`this`, `is`, `a`, `path`) - pb2 := Builder{}.Append(`this`, `is/a`, `path`) - - require.NotEqual(suite.T(), pb1, pb2) - assert.NotEqual(suite.T(), pb1.ShortRef(), pb2.ShortRef()) -} - -func (suite *PathUnitSuite) TestFromStringErrors() { +func (suite *PathUnitSuite) TestFromDataLayerPathErrors() { table := []struct { name string escapedPath string @@ -521,82 +288,7 @@ func (suite *PathUnitSuite) TestFromStringErrors() { } } -func (suite *PathUnitSuite) TestFolder() { - table := []struct { - name string - p func(t *testing.T) Path - escape bool - expectFolder string - expectSplit []string - }{ - { - name: "clean path", - p: func(t *testing.T) Path { - p, err := Builder{}. - Append("a", "b", "c"). - ToDataLayerExchangePathForCategory("t", "u", EmailCategory, false) - require.NoError(t, err, clues.ToCore(err)) - - return p - }, - expectFolder: "a/b/c", - expectSplit: []string{"a", "b", "c"}, - }, - { - name: "clean path escaped", - p: func(t *testing.T) Path { - p, err := Builder{}. - Append("a", "b", "c"). - ToDataLayerExchangePathForCategory("t", "u", EmailCategory, false) - require.NoError(t, err, clues.ToCore(err)) - - return p - }, - escape: true, - expectFolder: "a/b/c", - expectSplit: []string{"a", "b", "c"}, - }, - { - name: "escapable path", - p: func(t *testing.T) Path { - p, err := Builder{}. - Append("a/", "b", "c"). - ToDataLayerExchangePathForCategory("t", "u", EmailCategory, false) - require.NoError(t, err, clues.ToCore(err)) - - return p - }, - expectFolder: "a//b/c", - expectSplit: []string{"a", "b", "c"}, - }, - { - name: "escapable path escaped", - p: func(t *testing.T) Path { - p, err := Builder{}. - Append("a/", "b", "c"). - ToDataLayerExchangePathForCategory("t", "u", EmailCategory, false) - require.NoError(t, err, clues.ToCore(err)) - - return p - }, - escape: true, - expectFolder: "a\\//b/c", - expectSplit: []string{"a\\/", "b", "c"}, - }, - } - for _, test := range table { - suite.Run(test.name, func() { - t := suite.T() - - p := test.p(t) - result := p.Folder(test.escape) - assert.Equal(t, test.expectFolder, result) - assert.Equal(t, test.expectSplit, Split(result)) - }) - } -} - -func (suite *PathUnitSuite) TestFromString() { +func (suite *PathUnitSuite) TestFromDataLayerPath() { const ( testTenant = "tenant" testUser = "user" @@ -740,37 +432,7 @@ func (suite *PathUnitSuite) TestFromString() { } } -func (suite *PathUnitSuite) TestPath_piiHandling() { - p, err := Build("t", "ro", ExchangeService, EventsCategory, true, "dir", "item") - require.NoError(suite.T(), err) - - table := []struct { - name string - p Path - expect string - expectPlain string - }{ - { - name: "standard path", - p: p, - expect: "***/exchange/***/events/***/***", - expectPlain: "t/exchange/ro/events/dir/item", - }, - } - for _, test := range table { - suite.Run(test.name, func() { - t := suite.T() - - assert.Equal(t, test.expect, test.p.Conceal(), "conceal") - assert.Equal(t, test.expectPlain, test.p.String(), "string") - assert.Equal(t, test.expect, fmt.Sprintf("%s", test.p), "fmt %%s") - assert.Equal(t, test.expect, fmt.Sprintf("%+v", test.p), "fmt %%+v") - assert.Equal(t, test.expectPlain, test.p.PlainString(), "plain") - }) - } -} - -func (suite *PathUnitSuite) TestToServicePrefix() { +func (suite *PathUnitSuite) TestBuildPrefix() { table := []struct { name string service ServiceType @@ -818,7 +480,7 @@ func (suite *PathUnitSuite) TestToServicePrefix() { suite.Run(test.name, func() { t := suite.T() - r, err := ServicePrefix(test.tenant, test.owner, test.service, test.category) + r, err := BuildPrefix(test.tenant, test.owner, test.service, test.category) test.expectErr(t, err, clues.ToCore(err)) if r == nil { diff --git a/src/pkg/path/resource_path.go b/src/pkg/path/resource_path.go index 923d66453..10dd71eb7 100644 --- a/src/pkg/path/resource_path.go +++ b/src/pkg/path/resource_path.go @@ -1,154 +1,9 @@ package path import ( - "fmt" - "strings" - "github.com/alcionai/clues" ) -var ErrorUnknownService = clues.New("unknown service string") - -// ServiceType denotes what service the path corresponds to. Metadata services -// are also included though they are only used for paths that house metadata for -// Corso backups. -// -// Metadata services are not considered valid service types for resource paths -// though they can be used for metadata paths. -// -// The order of the enums below can be changed, but the string representation of -// each enum must remain the same or migration code needs to be added to handle -// changes to the string format. -type ServiceType int - -//go:generate stringer -type=ServiceType -linecomment -const ( - UnknownService ServiceType = iota - ExchangeService // exchange - OneDriveService // onedrive - SharePointService // sharepoint - ExchangeMetadataService // exchangeMetadata - OneDriveMetadataService // onedriveMetadata - SharePointMetadataService // sharepointMetadata -) - -func toServiceType(service string) ServiceType { - s := strings.ToLower(service) - - switch s { - case strings.ToLower(ExchangeService.String()): - return ExchangeService - case strings.ToLower(OneDriveService.String()): - return OneDriveService - case strings.ToLower(SharePointService.String()): - return SharePointService - case strings.ToLower(ExchangeMetadataService.String()): - return ExchangeMetadataService - case strings.ToLower(OneDriveMetadataService.String()): - return OneDriveMetadataService - case strings.ToLower(SharePointMetadataService.String()): - return SharePointMetadataService - default: - return UnknownService - } -} - -var ErrorUnknownCategory = clues.New("unknown category string") - -// CategoryType denotes what category of data the path corresponds to. The order -// of the enums below can be changed, but the string representation of each enum -// must remain the same or migration code needs to be added to handle changes to -// the string format. -type CategoryType int - -//go:generate stringer -type=CategoryType -linecomment -const ( - UnknownCategory CategoryType = iota - EmailCategory // email - ContactsCategory // contacts - EventsCategory // events - FilesCategory // files - ListsCategory // lists - LibrariesCategory // libraries - PagesCategory // pages - DetailsCategory // details -) - -func ToCategoryType(category string) CategoryType { - cat := strings.ToLower(category) - - switch cat { - case strings.ToLower(EmailCategory.String()): - return EmailCategory - case strings.ToLower(ContactsCategory.String()): - return ContactsCategory - case strings.ToLower(EventsCategory.String()): - return EventsCategory - case strings.ToLower(FilesCategory.String()): - return FilesCategory - case strings.ToLower(LibrariesCategory.String()): - return LibrariesCategory - case strings.ToLower(ListsCategory.String()): - return ListsCategory - case strings.ToLower(PagesCategory.String()): - return PagesCategory - case strings.ToLower(DetailsCategory.String()): - return DetailsCategory - default: - return UnknownCategory - } -} - -// serviceCategories is a mapping of all valid service/category pairs for -// non-metadata paths. -var serviceCategories = map[ServiceType]map[CategoryType]struct{}{ - ExchangeService: { - EmailCategory: {}, - ContactsCategory: {}, - EventsCategory: {}, - }, - OneDriveService: { - FilesCategory: {}, - }, - SharePointService: { - LibrariesCategory: {}, - ListsCategory: {}, - PagesCategory: {}, - }, -} - -func validateServiceAndCategoryStrings(s, c string) (ServiceType, CategoryType, error) { - service := toServiceType(s) - if service == UnknownService { - return UnknownService, UnknownCategory, clues.Stack(ErrorUnknownService).With("service", fmt.Sprintf("%q", s)) - } - - category := ToCategoryType(c) - if category == UnknownCategory { - return UnknownService, UnknownCategory, clues.Stack(ErrorUnknownService).With("category", fmt.Sprintf("%q", c)) - } - - if err := ValidateServiceAndCategory(service, category); err != nil { - return UnknownService, UnknownCategory, err - } - - return service, category, nil -} - -func ValidateServiceAndCategory(service ServiceType, category CategoryType) error { - cats, ok := serviceCategories[service] - if !ok { - return clues.New("unsupported service").With("service", fmt.Sprintf("%q", service)) - } - - if _, ok := cats[category]; !ok { - return clues.New("unknown service/category combination"). - With("service", fmt.Sprintf("%q", service), "category", fmt.Sprintf("%q", category)) - } - - return nil -} - // dataLayerResourcePath allows callers to extract information from a // resource-specific path. This struct is unexported so that callers are // forced to use the pre-defined constructors, making it impossible to create a diff --git a/src/pkg/path/service_type.go b/src/pkg/path/service_type.go new file mode 100644 index 000000000..318de2c62 --- /dev/null +++ b/src/pkg/path/service_type.go @@ -0,0 +1,53 @@ +package path + +import ( + "strings" + + "github.com/alcionai/clues" +) + +var ErrorUnknownService = clues.New("unknown service string") + +// ServiceType denotes what service the path corresponds to. Metadata services +// are also included though they are only used for paths that house metadata for +// Corso backups. +// +// Metadata services are not considered valid service types for resource paths +// though they can be used for metadata paths. +// +// The order of the enums below can be changed, but the string representation of +// each enum must remain the same or migration code needs to be added to handle +// changes to the string format. +type ServiceType int + +//go:generate stringer -type=ServiceType -linecomment +const ( + UnknownService ServiceType = iota + ExchangeService // exchange + OneDriveService // onedrive + SharePointService // sharepoint + ExchangeMetadataService // exchangeMetadata + OneDriveMetadataService // onedriveMetadata + SharePointMetadataService // sharepointMetadata +) + +func toServiceType(service string) ServiceType { + s := strings.ToLower(service) + + switch s { + case strings.ToLower(ExchangeService.String()): + return ExchangeService + case strings.ToLower(OneDriveService.String()): + return OneDriveService + case strings.ToLower(SharePointService.String()): + return SharePointService + case strings.ToLower(ExchangeMetadataService.String()): + return ExchangeMetadataService + case strings.ToLower(OneDriveMetadataService.String()): + return OneDriveMetadataService + case strings.ToLower(SharePointMetadataService.String()): + return SharePointMetadataService + default: + return UnknownService + } +} diff --git a/src/pkg/selectors/reasons.go b/src/pkg/selectors/reasons.go index a44cf5c49..a27d87f52 100644 --- a/src/pkg/selectors/reasons.go +++ b/src/pkg/selectors/reasons.go @@ -38,7 +38,7 @@ func (br backupReason) Category() path.CategoryType { } func (br backupReason) SubtreePath() (path.Path, error) { - return path.ServicePrefix( + return path.BuildPrefix( br.tenant, br.resource, br.service,