diff --git a/go.mod b/go.mod index 0abdf6f..ac857c8 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,13 @@ module github.com/ucan-wg/go-ucan go 1.15 require ( - github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible github.com/ipfs/go-cid v0.0.7 github.com/libp2p/go-libp2p-core v0.7.0 github.com/multiformats/go-multibase v0.0.3 github.com/multiformats/go-multihash v0.0.14 github.com/multiformats/go-varint v0.0.6 + github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 // indirect ) diff --git a/go.sum b/go.sum index bad317a..1636311 100644 --- a/go.sum +++ b/go.sum @@ -71,13 +71,22 @@ github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXS github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spacemonkeygo/spacelog v0.0.0-20180420211403-2296661a0572 h1:RC6RW7j+1+HkWaX/Yh71Ee5ZHaHYt7ZP4sQgUrm6cDU= github.com/spacemonkeygo/spacelog v0.0.0-20180420211403-2296661a0572/go.mod h1:w0SWMsp6j9O/dk4/ZpIhL+3CkG8ofA2vuv7k+ltqUMc= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -124,9 +133,13 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/command/command.go b/pkg/command/command.go new file mode 100644 index 0000000..3c51416 --- /dev/null +++ b/pkg/command/command.go @@ -0,0 +1,142 @@ +package command + +import ( + "errors" + "fmt" + "strings" +) + +const ( + separator = "/" +) + +// ErrNew indicates that the wrapped error was encountered while creating +// a new Command. +var ErrNew = errors.New("failed to create Command from elems") + +// ErrParse indicates that the wrapped error was encountered while +// attempting to parse a string as a Command. +var ErrParse = errors.New("failed to parse Command") + +// ErrorJoin indicates that the wrapped error was encountered while +// attempting to join a new segment to a Command. +var ErrJoin = errors.New("failed to join segments to Command") + +// ErrRequiresLeadingSlash is returned when a parsing a string that +// doesn't start with a [leading slash character]. +// +// [leading slash character]: https://github.com/ucan-wg/spec#segment-structure +var ErrRequiresLeadingSlash = parseError("a command requires a leading slash character") + +// ErrDisallowsTrailingSlash is returned when parsing a string that [ends +// with a trailing slash character]. +// +// [ends with a trailing slash character]: https://github.com/ucan-wg/spec#segment-structure +var ErrDisallowsTrailingSlash = parseError("a command must not include a trailing slash") + +// ErrUCANNamespaceReserved is returned to indicate that a Command's +// first segment would contain the [reserved "ucan" namespace]. +// +// [reserved "ucan" namespace]: https://github.com/ucan-wg/spec#ucan-namespace +var ErrUCANNamespaceReserved = errors.New("the UCAN namespace is reserved") + +// ErrRequiresLowercase is returned if a Command contains, or would contain, +// [uppercase unicode characters]. +// +// [uppercase unicode characters]: https://github.com/ucan-wg/spec#segment-structure +var ErrRequiresLowercase = parseError("UCAN path segments must must not contain upper-case characters") + +func parseError(msg string) error { + return fmt.Errorf("%w: %s", ErrParse, msg) +} + +var _ fmt.Stringer = (*Command)(nil) + +// Command is a concrete messages (a "verb") that MUST be unambiguously +// interpretable by the Subject of a UCAN. +// +// A [Command] is composed of a leading slash which is optionally followed +// by one or more slash-separated Segments of lowercase characters. +// +// [Command]: https://github.com/ucan-wg/spec#command +type Command struct { + segments []string +} + +// New creates a validated command from the provided list of segment +// strings. An error is returned if an invalid Command would be +// formed +func New(segments ...string) (*Command, error) { + return newCommand(ErrNew, segments...) +} + +func newCommand(err error, segments ...string) (*Command, error) { + if len(segments) > 0 && segments[0] == "ucan" { + return nil, fmt.Errorf("%w: %w", err, ErrUCANNamespaceReserved) + } + + cmd := Command{segments} + + return &cmd, nil +} + +// Parse verifies that the provided string contains the required +// [segment structure] and, if valid, returns the resulting +// Command. +// +// [segment structure]: https://github.com/ucan-wg/spec#segment-structure +func Parse(s string) (*Command, error) { + if !strings.HasPrefix(s, "/") { + return nil, ErrRequiresLeadingSlash + } + + if len(s) > 1 && strings.HasSuffix(s, "/") { + return nil, ErrDisallowsTrailingSlash + } + + if s != strings.ToLower(s) { + return nil, ErrRequiresLowercase + } + + // The leading slash will result in the first element from strings.Split + // being an empty string which is removed as strings.Join will ignore it. + return newCommand(ErrParse, strings.Split(s, "/")[1:]...) +} + +// [Top] is the most powerful capability. +// +// This function returns a Command that is a wildcard and therefore represents the +// most powerful abilily. As such it should be handle with care and used +// sparingly. +// +// [Top]: https://github.com/ucan-wg/spec#-aka-top +func Top() *Command { + cmd, _ := New() + + return cmd +} + +// IsValid returns true if the provided string is a valid UCAN command. +func IsValid(s string) bool { + _, err := Parse(s) + + return err == nil +} + +// Join appends segments to the end of this command using the required +// segment separator. +func (c *Command) Join(segments ...string) (*Command, error) { + return newCommand(ErrJoin, append(c.segments, segments...)...) +} + +// Segments returns the ordered segments that comprise the Command as a +// slice of strings. +func (c *Command) Segments() []string { + return c.segments +} + +// String returns the composed representation the command. This is also +// the required wire representation (before IPLD encoding occurs.) +func (c *Command) String() string { + return "/" + strings.Join([]string(c.segments), "/") +} diff --git a/pkg/command/command_test.go b/pkg/command/command_test.go new file mode 100644 index 0000000..7d83004 --- /dev/null +++ b/pkg/command/command_test.go @@ -0,0 +1,159 @@ +package command_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/pkg/command" +) + +func TestCommand_String(t *testing.T) { + require.Equal(t, "/", command.Top().String()) +} + +func TestIsValidCommand(t *testing.T) { + t.Parallel() + + t.Run("succeeds when", func(t *testing.T) { + t.Parallel() + + for _, testcase := range validTestcases(t) { + testcase := testcase + + t.Run(testcase.name, func(t *testing.T) { + t.Parallel() + + require.True(t, command.IsValid(testcase.inp)) + }) + } + }) + + t.Run("fails when", func(t *testing.T) { + t.Parallel() + + for _, testcase := range invalidTestcases(t) { + testcase := testcase + + t.Run(testcase.name, func(t *testing.T) { + t.Parallel() + + require.False(t, command.IsValid(testcase.inp)) + }) + } + }) +} + +func TestParseCommand(t *testing.T) { + t.Parallel() + + t.Run("succeeds when", func(t *testing.T) { + t.Parallel() + + for _, testcase := range validTestcases(t) { + testcase := testcase + + t.Run(testcase.name, func(t *testing.T) { + t.Parallel() + + cmd, err := command.Parse("/elem0/elem1/elem2") + require.NoError(t, err) + require.NotNil(t, cmd) + }) + } + }) + + t.Run("fails when", func(t *testing.T) { + t.Parallel() + + for _, testcase := range invalidTestcases(t) { + testcase := testcase + + t.Run(testcase.name, func(t *testing.T) { + t.Parallel() + + cmd, err := command.Parse(testcase.inp) + assert.ErrorIs(t, err, command.ErrParse) + assert.ErrorIs(t, err, testcase.err) + assert.Nil(t, cmd) + }) + } + }) +} + +type testcase struct { + name string + inp string +} + +func validTestcases(t *testing.T) []testcase { + t.Helper() + + cmds := []string{ + "/", + "/crud", + "/crud/create", + "/stack/pop", + "/crypto/sign", + "/foo/bar/baz/qux/quux", + "/ほげ/ふが", + } + + testcases := make([]testcase, len(cmds)) + + for i, inp := range cmds { + testcases[i] = testcase{ + name: "Command is " + inp, + inp: inp, + } + } + + return testcases +} + +type errorTestcase struct { + testcase + err error +} + +func invalidTestcases(t *testing.T) []errorTestcase { + t.Helper() + + return []errorTestcase{ + { + testcase: testcase{ + name: "leading slash is missing", + inp: "elem0/elem1/elem2", + }, + err: command.ErrRequiresLeadingSlash, + }, + { + testcase: testcase{ + name: "trailing slash is present", + inp: "/elem0/elem1/elem2/", + }, + err: command.ErrDisallowsTrailingSlash, + }, + { + testcase: testcase{ + name: "only reserved ucan namespace", + inp: "/ucan", + }, + err: command.ErrUCANNamespaceReserved, + }, + { + testcase: testcase{ + name: "reserved ucan namespace prefix", + inp: "/ucan/elem0/elem1/elem2", + }, + err: command.ErrUCANNamespaceReserved, + }, + { + testcase: testcase{ + name: "uppercase character are present", + inp: "/elem0/Elem1/elem2", + }, + err: command.ErrRequiresLowercase, + }, + } +}