From 9fe6ca7f56d58b1f07abfce85174494c072dea43 Mon Sep 17 00:00:00 2001 From: ashmrtn <3891298+ashmrtn@users.noreply.github.com> Date: Wed, 31 Aug 2022 13:21:46 -0700 Subject: [PATCH] Logic to create Path structs from strings (#691) ## Description * logic to split escaped path strings into elements * wire up validity checks/struct construction * tests for both invalid and valid path strings ## Type of change Please check the type of change your PR introduces: - [x] :sunflower: Feature - [ ] :bug: Bugfix - [ ] :world_map: Documentation - [ ] :robot: Test - [ ] :hamster: Trivial/Minor ## Issue(s) part of #671 merge after: * #648 * #689 * #690 ## Test Plan - [ ] :muscle: Manual - [x] :zap: Unit test - [ ] :green_heart: E2E --- src/internal/path/path.go | 92 ++++++++++++++++++ src/internal/path/path_test.go | 165 +++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) diff --git a/src/internal/path/path.go b/src/internal/path/path.go index f158473aa..297a22347 100644 --- a/src/internal/path/path.go +++ b/src/internal/path/path.go @@ -41,6 +41,8 @@ import ( "github.com/pkg/errors" ) +const templateErrPathParsing = "parsing resource path from %s" + const ( escapeCharacter = '\\' pathSeparator = '/' @@ -217,6 +219,43 @@ func (pb Builder) ToDataLayerExchangeMailItem(tenant, user string) (Path, error) }, nil } +// 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 +// is malformed returns an error. +func FromDataLayerPath(p string, isItem bool) (Path, error) { + p = trimTrailingSlash(p) + // If p was just the path separator then it will be empty now. + if len(p) == 0 { + return nil, errors.Errorf("logically empty path given: %s", p) + } + + // Turn into a Builder to reuse code that ignores empty elements. + pb, err := Builder{}.UnescapeAndAppend(split(p)...) + if err != nil { + return nil, errors.Wrapf(err, templateErrPathParsing, p) + } + + if len(pb.elements) < 5 { + return nil, errors.Errorf("path has too few segments: %s", p) + } + + service, category, err := validateServiceAndCategory( + pb.elements[1], + pb.elements[3], + ) + if err != nil { + return nil, errors.Wrapf(err, templateErrPathParsing, p) + } + + return &dataLayerResourcePath{ + Builder: *pb, + service: service, + category: category, + hasItem: isItem, + }, nil +} + // escapeElement takes a single path element and escapes all characters that // require an escape sequence. If there are no characters that need escaping, // the input is returned unchanged. @@ -346,3 +385,56 @@ func join(elements []string) string { // '\' according to the escaping rules. return strings.Join(elements, string(pathSeparator)) } + +// split takes an escaped string and returns a slice of path elements. The +// string is split on the path separator according to the escaping rules. The +// provided string must not contain an unescaped trailing path separator. +func split(segment string) []string { + res := make([]string, 0) + numEscapes := 0 + startIdx := 0 + // Start with true to ignore leading separator. + prevWasSeparator := true + + for i, c := range segment { + if c == escapeCharacter { + prevWasSeparator = false + numEscapes++ + + continue + } + + if c != pathSeparator { + prevWasSeparator = false + numEscapes = 0 + + continue + } + + // Remaining is just path separator handling. + if numEscapes%2 != 0 { + // This is an escaped separator. + prevWasSeparator = false + numEscapes = 0 + + continue + } + + // Ignore leading separator characters and don't add elements that would + // be empty. + if !prevWasSeparator { + res = append(res, segment[startIdx:i]) + } + + // We don't want to include the path separator in the result. + startIdx = i + 1 + prevWasSeparator = true + numEscapes = 0 + } + + // Add the final segment because the loop above won't catch it. There should + // be no trailing separator character. + res = append(res, segment[startIdx:]) + + return res +} diff --git a/src/internal/path/path_test.go b/src/internal/path/path_test.go index 88d3252fc..2a5ffb551 100644 --- a/src/internal/path/path_test.go +++ b/src/internal/path/path_test.go @@ -293,3 +293,168 @@ func (suite *PathUnitSuite) TestTrailingEscapeChar() { }) } } + +func (suite *PathUnitSuite) TestFromStringErrors() { + table := []struct { + name string + escapedPath string + }{ + { + name: "TooFewElements", + escapedPath: `some/short/path`, + }, + { + name: "TooFewElementsEmptyElement", + escapedPath: `tenant/exchange//email/folder`, + }, + { + name: "BadEscapeSequence", + escapedPath: `tenant/exchange/user/email/folder\a`, + }, + { + name: "TrailingEscapeCharacter", + escapedPath: `tenant/exchange/user/email/folder\`, + }, + { + name: "UnknownService", + escapedPath: `tenant/badService/user/email/folder`, + }, + { + name: "UnknownCategory", + escapedPath: `tenant/exchange/user/badCategory/folder`, + }, + { + name: "NoFolderOrItem", + escapedPath: `tenant/exchange/user/email`, + }, + { + name: "EmptyPath", + escapedPath: ``, + }, + { + name: "JustPathSeparator", + escapedPath: `/`, + }, + { + name: "JustMultiplePathSeparators", + escapedPath: `//`, + }, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + _, err := FromDataLayerPath(test.escapedPath, false) + assert.Error(t, err) + }) + } +} + +func (suite *PathUnitSuite) TestFromString() { + const ( + testTenant = "tenant" + testUser = "user" + testElement1 = "folder" + testElement2 = "folder2" + testElement3 = "other" + ) + + isItem := []struct { + name string + isItem bool + }{ + { + name: "Folder", + isItem: false, + }, + { + name: "Item", + isItem: true, + }, + } + table := []struct { + name string + // Should have placeholders of '%s' for service and category. + unescapedPath string + // Expected result for Folder() if path is marked as a folder. + expectedFolder string + // Expected result for Item() if path is marked as an item. + expectedItem string + // Expected result for Folder() if path is marked as an item. + expectedItemFolder string + }{ + { + name: "BasicPath", + unescapedPath: fmt.Sprintf( + "%s/%%s/%s/%%s/%s/%s/%s", + testTenant, + testUser, + testElement1, + testElement2, + testElement3, + ), + expectedFolder: fmt.Sprintf( + "%s/%s/%s", + testElement1, + testElement2, + testElement3, + ), + expectedItem: testElement3, + expectedItemFolder: fmt.Sprintf( + "%s/%s", + testElement1, + testElement2, + ), + }, + { + name: "PathWithEmptyElements", + unescapedPath: fmt.Sprintf( + "/%s//%%s//%s//%%s//%s///%s//%s//", + testTenant, + testUser, + testElement1, + testElement2, + testElement3, + ), + expectedFolder: fmt.Sprintf( + "%s/%s/%s", + testElement1, + testElement2, + testElement3, + ), + expectedItem: testElement3, + expectedItemFolder: fmt.Sprintf( + "%s/%s", + testElement1, + testElement2, + ), + }, + } + + for service, cats := range serviceCategories { + for cat := range cats { + for _, item := range isItem { + suite.T().Run(fmt.Sprintf("%s-%s-%s", service, cat, item.name), func(t1 *testing.T) { + for _, test := range table { + t1.Run(test.name, func(t *testing.T) { + testPath := fmt.Sprintf(test.unescapedPath, service, cat) + + p, err := FromDataLayerPath(testPath, item.isItem) + require.NoError(t, err) + + assert.Equal(t, service, p.Service()) + assert.Equal(t, cat, p.Category()) + assert.Equal(t, testTenant, p.Tenant()) + assert.Equal(t, testUser, p.ResourceOwner()) + + if !item.isItem { + assert.Equal(t, test.expectedFolder, p.Folder()) + } else { + assert.Equal(t, test.expectedItemFolder, p.Folder()) + assert.Equal(t, test.expectedItem, p.Item()) + } + }) + } + }) + } + } + } +}