diff --git a/capability/command/command.go b/capability/command/command.go new file mode 100644 index 0000000..3c51416 --- /dev/null +++ b/capability/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/capability/command/command_test.go b/capability/command/command_test.go new file mode 100644 index 0000000..7d83004 --- /dev/null +++ b/capability/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, + }, + } +}