From ea3c9c035e2377b66cecba53a0a60e4d6dd0eebf Mon Sep 17 00:00:00 2001 From: ashmrtn <3891298+ashmrtn@users.noreply.github.com> Date: Mon, 25 Jul 2022 13:31:09 -0700 Subject: [PATCH] Implement and test one path constructor and join (#398) Implement and test one path constructor and join --- src/internal/path/path.go | 96 +++++++++++++++++++++++++--- src/internal/path/path_test.go | 111 +++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 src/internal/path/path_test.go diff --git a/src/internal/path/path.go b/src/internal/path/path.go index 277b57c5b..f3914968d 100644 --- a/src/internal/path/path.go +++ b/src/internal/path/path.go @@ -37,7 +37,21 @@ package path import ( - "errors" + "strings" + + "github.com/pkg/errors" +) + +const ( + escapeCharacter = '\\' + pathSeparator = '/' +) + +var ( + charactersToEscape = map[rune]struct{}{ + pathSeparator: {}, + escapeCharacter: {}, + } ) var errMissingSegment = errors.New("missing required path segment") @@ -57,12 +71,35 @@ type Path interface { } type Base struct { + // Escaped path elements. + elements []string + // Contains starting index in elements of each segment. + segmentIdx []int } // newPath takes a path that is broken into segments and elements in the segment // and returns a Base. Each element in the input is escaped. func newPath(segments [][]string) Base { - return Base{} + if len(segments) == 0 { + return Base{} + } + + res := Base{segmentIdx: make([]int, 0, len(segments))} + idx := 0 + for _, s := range segments { + res.segmentIdx = append(res.segmentIdx, idx) + + for _, e := range s { + if len(e) == 0 { + continue + } + + res.elements = append(res.elements, escapeElement(e)) + idx++ + } + } + + return res } // NewPathFromEscapedSegments takes already escaped segments of a path, verifies @@ -74,14 +111,20 @@ func newPathFromEscapedSegments(segments []string) (Base, error) { // String returns a string that contains all path segments joined // together. Elements of the path that need escaping will be escaped. -func (p Base) String() string { - return "" +func (b Base) String() string { + return join(b.elements) } // segment returns the nth segment of the path. Path segment indices are -// 0-based. -func (p Base) segment(n int) string { - return "" +// 0-based. As this function is used exclusively by wrappers of path, it does no +// bounds checking. Callers are expected to have validated the number of +// segments when making the path. +func (b Base) segment(n int) string { + if n == len(b.segmentIdx)-1 { + return join(b.elements[b.segmentIdx[n]:]) + } + + return join(b.elements[b.segmentIdx[n]:b.segmentIdx[n+1]]) } // unescapedSegmentElements returns the unescaped version of the elements that @@ -93,6 +136,43 @@ func (p Base) unescapedSegmentElements(n int) []string { // TransformedSegments returns a slice of the path segments where each segments // has also been transformed such that it contains no characters outside the set // of acceptable file system path characters. -func (p Base) TransformedSegments() []string { +func (b Base) TransformedSegments() []string { return nil } + +func escapeElement(element string) string { + escapeIdx := make([]int, 0) + + for i, c := range element { + if _, ok := charactersToEscape[c]; ok { + escapeIdx = append(escapeIdx, i) + } + } + + if len(escapeIdx) == 0 { + return element + } + + b := strings.Builder{} + b.Grow(len(element) + len(escapeIdx)) + startIdx := 0 + + for _, idx := range escapeIdx { + b.WriteString(element[startIdx:idx]) + b.WriteRune(escapeCharacter) + startIdx = idx + } + + // Add the end of the element after the last escape character. + b.WriteString(element[startIdx:]) + + return b.String() +} + +// join returns a string containing the given elements joined by the path +// separator '/'. +func join(elements []string) string { + // Have to use strings because path package does not handle escaped '/' and + // '\' according to the escaping rules. + return strings.Join(elements, string(pathSeparator)) +} diff --git a/src/internal/path/path_test.go b/src/internal/path/path_test.go new file mode 100644 index 000000000..2dd0b08a6 --- /dev/null +++ b/src/internal/path/path_test.go @@ -0,0 +1,111 @@ +package path + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type PathUnitSuite struct { + suite.Suite +} + +func TestPathUnitSuite(t *testing.T) { + suite.Run(t, new(PathUnitSuite)) +} + +func (suite *PathUnitSuite) TestPathEscapingAndSegments() { + table := []struct { + name string + input [][]string + expected string + }{ + { + name: "SimplePath", + input: [][]string{ + {`this`}, + {`is`}, + {`a`}, + {`path`}, + }, + expected: "this/is/a/path", + }, + { + name: "EscapeSeparator", + input: [][]string{ + {`this`}, + {`is/a`}, + {`path`}, + }, + expected: `this/is\/a/path`, + }, + { + name: "EscapeEscapeChar", + input: [][]string{ + {`this`}, + {`is\`}, + {`a`}, + {`path`}, + }, + expected: `this/is\\/a/path`, + }, + { + name: "EscapeEscapeAndSeparator", + input: [][]string{ + {`this`}, + {`is\/a`}, + {`path`}, + }, + expected: `this/is\\\/a/path`, + }, + { + name: "EmptyInternalElement", + input: [][]string{ + {`this`}, + {`is`}, + {""}, + {`a`}, + {`path`}, + }, + expected: "this/is/a/path", + }, + { + name: "SeparatorAtEndOfElement", + input: [][]string{ + {`this`}, + {`is/`}, + {`a`}, + {`path`}, + }, + expected: `this/is\//a/path`, + }, + { + name: "SeparatorAtEndOfPath", + input: [][]string{ + {`this`}, + {`is`}, + {`a`}, + {`path/`}, + }, + expected: `this/is/a/path\/`, + }, + } + + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + p := newPath(test.input) + assert.Equal(t, test.expected, p.String()) + + for i := 0; i < len(test.input); i++ { + assert.NotPanics(t, func() { + _ = p.segment(i) + }) + } + + assert.Panics(t, func() { + _ = p.segment(len(test.input)) + }) + }) + } +}