Repository: puppetlabs/wash
Pull request #209 (2019-04-12 22:16:18)

Require absolute paths for the Wash API
This removes the dependency on xattr for determining the API key. It
also paves the way for the API to support regular files/directories
(i.e. non-Wash entries) in subsequent commits.

Signed-off-by: Enis Inan <enis.inan@puppet.com>

Contributions to this project require sign-off consistent with the [Developers Certificate of Origin](https://developercertificate.org). This can be as simple as using `git commit -s` on each commit.

ekinanp: We'll need `apitypes.Entry#Path` for `wash find`, specifically for the `path` primary and to make querying children easier w/o having to calculate and use the absolute path. Thus, I've kept it for now.

PR's been updated to only fix `wash find`.

Patch:
diff --git a/api/client/client.go b/api/client/client.go
index 009f6fa..fbece62 100644
--- a/api/client/client.go
+++ b/api/client/client.go
@@ -17,12 +17,11 @@ import (
 	"net/http"
 	"net/url"
 	"os"
+	"path/filepath"
 
 	"github.com/Benchkram/errz"
 	apitypes "github.com/puppetlabs/wash/api/types"
 	"github.com/puppetlabs/wash/journal"
-
-	"github.com/pkg/xattr"
 )
 
 // A DomainSocketClient is a wash API client.
@@ -46,6 +45,11 @@ func ForUNIXSocket(pathToSocket string) DomainSocketClient {
 }
 
 func (c *DomainSocketClient) doRequest(method, endpoint, path string, body io.Reader) (io.ReadCloser, error) {
+	path, err := filepath.Abs(path)
+	if err != nil {
+		return nil, fmt.Errorf("could not calculate the absolute path of %v: %v", path, err)
+	}
+
 	req, err := http.NewRequest(method, domainSocketBaseURL, body)
 	if err != nil {
 		return nil, err
@@ -195,14 +199,3 @@ func (c *DomainSocketClient) Clear(path string) ([]string, error) {
 
 	return result, nil
 }
-
-// APIKeyFromPath will take a path to an object within the wash filesystem,
-// and interrogate it to determine its path relative to the wash filesystem
-// root. This is stored in the extended attributes of every file in the wash fs.
-func APIKeyFromPath(fspath string) (string, error) {
-	p, err := xattr.Get(fspath, "wash.id")
-	if err != nil {
-		return "", err
-	}
-	return string(p), nil
-}
diff --git a/api/error.go b/api/error.go
index e946652..d27e7fd 100644
--- a/api/error.go
+++ b/api/error.go
@@ -131,7 +131,7 @@ func erroredActionResponse(path string, a plugin.Action, reason string) *errorRe
 
 func duplicateCNameResponse(e plugin.DuplicateCNameErr) *errorResponse {
 	fields := apitypes.ErrorFields{
-		"parent_path":                         e.ParentPath,
+		"parent_id":                           e.ParentID,
 		"first_child_name":                    e.FirstChildName,
 		"first_child_slash_replacement_char":  e.FirstChildSlashReplacementChar,
 		"second_child_name":                   e.SecondChildName,
@@ -147,3 +147,31 @@ func duplicateCNameResponse(e plugin.DuplicateCNameErr) *errorResponse {
 
 	return &errorResponse{http.StatusInternalServerError, body}
 }
+
+func relativePathResponse(path string) *errorResponse {
+	fields := apitypes.ErrorFields{
+		"path": path,
+	}
+
+	body := newErrorObj(
+		apitypes.RelativePath,
+		fmt.Sprintf("%v is a relative path. The Wash API only accepts absolute paths.", path),
+		fields,
+	)
+
+	return &errorResponse{http.StatusBadRequest, body}
+}
+
+func nonWashEntryResponse(path string) *errorResponse {
+	fields := apitypes.ErrorFields{
+		"path": path,
+	}
+
+	body := newErrorObj(
+		apitypes.NonWashEntry,
+		fmt.Sprintf("%v is not a Wash entry.", path),
+		fields,
+	)
+
+	return &errorResponse{http.StatusBadRequest, body}
+}
diff --git a/api/helpers.go b/api/helpers.go
index 57cca27..cc43855 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -3,6 +3,7 @@ package api
 import (
 	"context"
 	"fmt"
+	"path/filepath"
 	"strings"
 
 	apitypes "github.com/puppetlabs/wash/api/types"
@@ -11,7 +12,6 @@ import (
 
 func toAPIEntry(e plugin.Entry) apitypes.Entry {
 	return apitypes.Entry{
-		Path:       plugin.Path(e),
 		Name:       plugin.Name(e),
 		CName:      plugin.CName(e),
 		Actions:    plugin.SupportedActionsOf(e),
@@ -63,16 +63,26 @@ func getEntryFromPath(ctx context.Context, path string) (plugin.Entry, *errorRes
 	if path == "" {
 		panic("path should never be empty")
 	}
+	if !filepath.IsAbs(path) {
+		return nil, relativePathResponse(path)
+	}
+
+	mountpoint := ctx.Value(mountpointKey).(string)
+	trimmedPath := strings.TrimPrefix(path, mountpoint)
+	if trimmedPath == path {
+		// TODO: Handle these later by wrapping them into entries
+		return nil, nonWashEntryResponse(path)
+	}
+	// Don't interpret trailing slash as a new segment, and ignore optional leading slash
+	path = strings.Trim(trimmedPath, "/")
 
 	// Get the registry from context (added by registry middleware).
 	registry := ctx.Value(pluginRegistryKey).(*plugin.Registry)
-	if path == "/" {
+	if path == "" {
 		// Return the registry
 		return registry, nil
 	}
 
-	// Don't interpret trailing slash as a new segment, and ignore optional leading slash
-	path = strings.Trim(path, "/")
 	// Split into plugin name and an optional list of segments.
 	segments := strings.Split(path, "/")
 	pluginName := segments[0]
diff --git a/api/info.go b/api/info.go
index 04a2922..e93a736 100644
--- a/api/info.go
+++ b/api/info.go
@@ -21,6 +21,7 @@ var infoHandler handler = func(w http.ResponseWriter, r *http.Request, p params)
 	w.WriteHeader(http.StatusOK)
 	jsonEncoder := json.NewEncoder(w)
 	apiEntry := toAPIEntry(entry)
+	apiEntry.Path = path
 	if err := jsonEncoder.Encode(&apiEntry); err != nil {
 		journal.Record(ctx, "API: Info: marshalling %v errored: %v", path, err)
 		return unknownErrorResponse(fmt.Errorf("Could not marshal %v: %v", path, err))
diff --git a/api/list.go b/api/list.go
index c88da7e..082e0d0 100644
--- a/api/list.go
+++ b/api/list.go
@@ -53,7 +53,9 @@ var listHandler handler = func(w http.ResponseWriter, r *http.Request, p params)
 
 	result := make([]apitypes.Entry, 0, len(entries))
 	for _, entry := range entries {
-		result = append(result, toAPIEntry(entry))
+		apiEntry := toAPIEntry(entry)
+		apiEntry.Path = path + "/" + apiEntry.CName
+		result = append(result, apiEntry)
 	}
 	journal.Record(ctx, "API: List %v %+v", path, result)
 
diff --git a/api/server.go b/api/server.go
index 5dc81c1..156c158 100644
--- a/api/server.go
+++ b/api/server.go
@@ -19,7 +19,10 @@ import (
 
 type key int
 
-const pluginRegistryKey key = iota
+const (
+	pluginRegistryKey key = iota
+	mountpointKey
+)
 
 // swagger:parameters cacheDelete listEntries executeCommand getMetadata readContent streamUpdates
 type params struct {
@@ -70,7 +73,7 @@ func (handle handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 //   2. A read-only channel that signals whether the server was shutdown.
 //
 //   3. An error object
-func StartAPI(registry *plugin.Registry, socketPath string) (chan<- context.Context, <-chan struct{}, error) {
+func StartAPI(registry *plugin.Registry, mountpoint string, socketPath string) (chan<- context.Context, <-chan struct{}, error) {
 	log.Infof("API: started")
 
 	if _, err := os.Stat(socketPath); err == nil {
@@ -91,9 +94,10 @@ func StartAPI(registry *plugin.Registry, socketPath string) (chan<- context.Cont
 		return nil, nil, err
 	}
 
-	addPluginRegistryAndJournalIDMiddleware := func(next http.Handler) http.Handler {
+	prepareContextMiddleWare := func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			newctx := context.WithValue(r.Context(), pluginRegistryKey, registry)
+			newctx = context.WithValue(newctx, mountpointKey, mountpoint)
 			newctx = context.WithValue(newctx, journal.Key, r.Header.Get(apitypes.JournalIDHeader))
 
 			// Call the next handler, which can be another middleware in the chain, or the final handler.
@@ -111,7 +115,7 @@ func StartAPI(registry *plugin.Registry, socketPath string) (chan<- context.Cont
 	r.Handle("/fs/exec", execHandler).Methods(http.MethodPost)
 	r.Handle("/cache", cacheHandler).Methods(http.MethodDelete)
 
-	r.Use(addPluginRegistryAndJournalIDMiddleware)
+	r.Use(prepareContextMiddleWare)
 
 	httpServer := http.Server{Handler: r}
 
diff --git a/api/types/error.go b/api/types/error.go
index c17f48a..75ee0ea 100644
--- a/api/types/error.go
+++ b/api/types/error.go
@@ -39,4 +39,6 @@ const (
 	BadRequest         = "puppetlabs.wash/bad-request"
 	ErroredAction      = "puppetlabs.wash/errored-action"
 	DuplicateCName     = "puppetlabs.wash/duplicate-cname"
+	RelativePath       = "puppetlabs.wash/relative-path"
+	NonWashEntry       = "puppetlabs.wash/non-wash-entry"
 )
diff --git a/cmd/clear.go b/cmd/clear.go
index d6c7881..58a9022 100644
--- a/cmd/clear.go
+++ b/cmd/clear.go
@@ -34,14 +34,8 @@ func clearMain(cmd *cobra.Command, args []string) exitCode {
 	}
 	verbose := viper.GetBool("verbose")
 
-	apiPath, err := client.APIKeyFromPath(path)
-	if err != nil {
-		cmdutil.ErrPrintf("%v\n", err)
-		return exitCode{1}
-	}
-
 	conn := client.ForUNIXSocket(config.Socket)
-	cleared, err := conn.Clear(apiPath)
+	cleared, err := conn.Clear(path)
 	if err != nil {
 		cmdutil.ErrPrintf("%v\n", err)
 		return exitCode{1}
@@ -52,7 +46,7 @@ func clearMain(cmd *cobra.Command, args []string) exitCode {
 			fmt.Println("Cleared", p)
 		}
 	} else {
-		fmt.Println("Cleared", apiPath)
+		fmt.Println("Cleared", path)
 	}
 
 	return exitCode{0}
diff --git a/cmd/exec.go b/cmd/exec.go
index 6865a97..1b05636 100644
--- a/cmd/exec.go
+++ b/cmd/exec.go
@@ -75,15 +75,9 @@ func execMain(cmd *cobra.Command, args []string) exitCode {
 	command = args[1]
 	commandArgs = args[2:]
 
-	apiPath, err := client.APIKeyFromPath(path)
-	if err != nil {
-		cmdutil.ErrPrintf("%v\n", err)
-		return exitCode{1}
-	}
-
 	conn := client.ForUNIXSocket(config.Socket)
 
-	ch, err := conn.Exec(apiPath, command, commandArgs, apitypes.ExecOptions{})
+	ch, err := conn.Exec(path, command, commandArgs, apitypes.ExecOptions{})
 	if err != nil {
 		cmdutil.ErrPrintf("%v\n", err)
 		return exitCode{1}
diff --git a/cmd/find.go b/cmd/find.go
index 810f3bf..da1407f 100644
--- a/cmd/find.go
+++ b/cmd/find.go
@@ -49,21 +49,16 @@ func findMain(cmd *cobra.Command, args []string) exitCode {
 		return exitCode{1}
 	}
 
-	apiPath, err := client.APIKeyFromPath(path)
-	if err != nil {
-		cmdutil.ErrPrintf("%v\n", err)
-		return exitCode{1}
-	}
 	conn := client.ForUNIXSocket(config.Socket)
 
-	e, err := conn.Info(apiPath)
+	e, err := conn.Info(path)
 	if err != nil {
 		cmdutil.ErrPrintf("%v\n", err)
 		return exitCode{1}
 	}
 	entries := []apitypes.Entry{e}
 	if e.Supports(plugin.ListAction) {
-		children, err := conn.List(apiPath)
+		children, err := conn.List(path)
 		if err != nil {
 			cmdutil.ErrPrintf("%v\n", err)
 			return exitCode{1}
@@ -71,11 +66,14 @@ func findMain(cmd *cobra.Command, args []string) exitCode {
 		entries = append(entries, children...)
 	}
 
-	for _, entry := range entries {
+	for i, entry := range entries {
 		if p(&entry) {
-			// TODO: Include the cwd's directory in path (so that find
-			// prints out absolute paths).
-			fmt.Printf("%v\n", entry.Path)
+			entryPath := path
+			if len(entries) > 1 && i != 0 {
+				entryPath += "/"
+				entryPath += entry.CName
+			}
+			fmt.Printf("%v\n", entryPath)
 		}
 	}
 	return exitCode{0}
diff --git a/cmd/list.go b/cmd/list.go
index cce5f5d..8c3e9c7 100644
--- a/cmd/list.go
+++ b/cmd/list.go
@@ -55,7 +55,7 @@ func formatListEntries(apiPath string, ls []apitypes.Entry) string {
 		verbs := strings.Join(entry.Actions, ", ")
 
 		name := entry.CName
-		if len(ls) > 1 && entry.Path == apiPath {
+		if len(ls) > 1 && i == 0 {
 			// Represent the pwd as "."
 			name = "."
 		}
@@ -128,13 +128,23 @@ func listMain(cmd *cobra.Command, args []string) exitCode {
 		path = args[0]
 	}
 
-	apiPath, err := client.APIKeyFromPath(path)
+	err := listResource(path)
 	if err == nil {
-		err = listResource(apiPath)
-	} else {
-		err = listPath(path)
+		return exitCode{0}
+	}
+	// err != nil
+
+	apiErr, ok := err.(*apitypes.ErrorObj)
+	if !ok {
+		cmdutil.ErrPrintf("%v\n", err)
+		return exitCode{1}
+	}
+	if apiErr.Kind != apitypes.NonWashEntry {
+		cmdutil.ErrPrintf("%v\n", err)
+		return exitCode{1}
 	}
 
+	err = listPath(path)
 	if err != nil {
 		cmdutil.ErrPrintf("%v\n", err)
 		return exitCode{1}
diff --git a/cmd/meta.go b/cmd/meta.go
index 49a3107..221f00c 100644
--- a/cmd/meta.go
+++ b/cmd/meta.go
@@ -5,7 +5,7 @@ import (
 	"fmt"
 
 	"github.com/puppetlabs/wash/api/client"
-	"github.com/puppetlabs/wash/cmd/util"
+	cmdutil "github.com/puppetlabs/wash/cmd/util"
 	"github.com/puppetlabs/wash/config"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
@@ -44,15 +44,9 @@ func metaMain(cmd *cobra.Command, args []string) exitCode {
 		return exitCode{1}
 	}
 
-	apiPath, err := client.APIKeyFromPath(path)
-	if err != nil {
-		cmdutil.ErrPrintf("%v\n", err)
-		return exitCode{1}
-	}
-
 	conn := client.ForUNIXSocket(config.Socket)
 
-	metadata, err := conn.Metadata(apiPath)
+	metadata, err := conn.Metadata(path)
 	if err != nil {
 		cmdutil.ErrPrintf("%v\n", err)
 		return exitCode{1}
diff --git a/cmd/ps.go b/cmd/ps.go
index b37e2e1..2ea3369 100644
--- a/cmd/ps.go
+++ b/cmd/ps.go
@@ -170,31 +170,17 @@ func psMain(cmd *cobra.Command, args []string) exitCode {
 		paths = []string{cwd}
 	}
 
-	var keys []string
-	for _, path := range paths {
-		apiKey, err := client.APIKeyFromPath(path)
-		if err != nil {
-			cmdutil.ErrPrintf("Unable to get API key for %v: %v\n", path, err)
-		} else {
-			keys = append(keys, apiKey)
-		}
-	}
-	if len(keys) == 0 {
-		cmdutil.ErrPrintf("Error: no valid nodes found\n")
-		return exitCode{1}
-	}
-
 	conn := client.ForUNIXSocket(config.Socket)
 
 	results := make(map[string][]psresult)
 	// Prepulate the map so it doesn't change size while all the goroutines are adding data.
-	for _, key := range keys {
-		results[key] = []psresult{}
+	for _, path := range paths {
+		results[path] = []psresult{}
 	}
 
 	var wg sync.WaitGroup
-	wg.Add(len(keys))
-	for i, key := range keys {
+	wg.Add(len(paths))
+	for i, path := range paths {
 		go func(k string, idx int) {
 			defer wg.Done()
 			ch, err := conn.Exec(k, "sh", []string{}, apitypes.ExecOptions{Input: psScript})
@@ -208,14 +194,14 @@ func psMain(cmd *cobra.Command, args []string) exitCode {
 			} else {
 				results[k] = parseLines(k, out)
 			}
-		}(key, i)
+		}(path, i)
 	}
 
 	wg.Wait()
 
 	var stats []psresult
-	for _, key := range keys {
-		stats = append(stats, results[key]...)
+	for _, path := range paths {
+		stats = append(stats, results[path]...)
 	}
 
 	fmt.Print(formatStats(stats))
diff --git a/cmd/server.go b/cmd/server.go
index 488aaa5..05b037b 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"os/signal"
+	"path/filepath"
 	"runtime/pprof"
 	"strings"
 	"syscall"
@@ -60,6 +61,12 @@ func serverMain(cmd *cobra.Command, args []string) exitCode {
 	logfile := viper.GetString("logfile")
 	externalPluginsPath := viper.GetString("external-plugins")
 
+	mountpoint, err := filepath.Abs(mountpoint)
+	if err != nil {
+		cmdutil.ErrPrintf("Could not compute the absolute path of the mountpoint %v: %v", mountpoint, err)
+		return exitCode{1}
+	}
+
 	logFH, err := loadLogger(loglevel, logfile)
 	if err != nil {
 		cmdutil.ErrPrintf("Failed to load the logger: %v\n", err)
@@ -90,7 +97,7 @@ func serverMain(cmd *cobra.Command, args []string) exitCode {
 
 	plugin.InitCache()
 
-	apiServerStopCh, apiServerStoppedCh, err := api.StartAPI(registry, config.Socket)
+	apiServerStopCh, apiServerStoppedCh, err := api.StartAPI(registry, mountpoint, config.Socket)
 	if err != nil {
 		log.Warn(err)
 		return exitCode{1}
diff --git a/cmd/tail.go b/cmd/tail.go
index 02b7636..aa06ae0 100644
--- a/cmd/tail.go
+++ b/cmd/tail.go
@@ -53,15 +53,13 @@ func (w lineWriter) Write(b []byte) (int, error) {
 // Streams output via API to aggregator channel.
 // Returns nil if streaming's not supported on this path.
 func tailStream(conn client.DomainSocketClient, agg chan line, path string) io.Closer {
-	apiPath, err := client.APIKeyFromPath(path)
+	stream, err := conn.Stream(path)
 	if err != nil {
-		// Not a resource
-		return nil
-	}
-
-	var stream io.ReadCloser
-	if stream, err = conn.Stream(apiPath); err != nil {
 		if errObj, ok := err.(*apitypes.ErrorObj); ok {
+			if errObj.Kind == apitypes.NonWashEntry {
+				// Not a resource
+				return nil
+			}
 			if errObj.Kind == apitypes.UnsupportedAction {
 				// The resource exists but does not support the streaming action
 				return nil
diff --git a/fuse/core.go b/fuse/core.go
index f2e7ac7..5d27ce1 100644
--- a/fuse/core.go
+++ b/fuse/core.go
@@ -71,7 +71,7 @@ func newFuseNode(ftype string, parent plugin.Group, entry plugin.Entry) *fuseNod
 }
 
 func (f *fuseNode) String() string {
-	return plugin.Path(f.entry)
+	return plugin.ID(f.entry)
 }
 
 // Applies attributes where non-default, and sets defaults otherwise.
@@ -150,22 +150,6 @@ func (f *fuseNode) Attr(ctx context.Context, a *fuse.Attr) error {
 	return nil
 }
 
-func (f *fuseNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error {
-	log.Infof("FUSE: Listxattr[%v,jid=%v] %v", f.ftype, journal.GetID(ctx), f)
-	resp.Append("wash.id")
-	return nil
-}
-
-func (f *fuseNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error {
-	log.Infof("FUSE: Getxattr[%v,jid=%v] %v", f.ftype, journal.GetID(ctx), f)
-	switch req.Name {
-	case "wash.id":
-		resp.Xattr = []byte(f.String())
-	}
-
-	return nil
-}
-
 // ServeFuseFS starts serving a fuse filesystem that lists the registered plugins.
 // It returns three values:
 //   1. A channel to initiate the shutdown (stopCh).
diff --git a/fuse/file.go b/fuse/file.go
index c465724..c28d867 100644
--- a/fuse/file.go
+++ b/fuse/file.go
@@ -19,8 +19,6 @@ type file struct {
 
 var _ fs.Node = (*file)(nil)
 var _ = fs.NodeOpener(&file{})
-var _ = fs.NodeGetxattrer(&file{})
-var _ = fs.NodeListxattrer(&file{})
 
 func newFile(p plugin.Group, e plugin.Entry) *file {
 	return &file{newFuseNode("f", p, e)}
diff --git a/plugin/cache.go b/plugin/cache.go
index 2f80cd6..19b19ba 100644
--- a/plugin/cache.go
+++ b/plugin/cache.go
@@ -117,7 +117,7 @@ func CachedOp(ctx context.Context, opName string, entry Entry, ttl time.Duration
 // DuplicateCNameErr represents a duplicate cname error, which
 // occurs when at least two children have the same cname.
 type DuplicateCNameErr struct {
-	ParentPath                      string
+	ParentID                        string
 	FirstChildName                  string
 	FirstChildSlashReplacementChar  rune
 	SecondChildName                 string
@@ -127,14 +127,14 @@ type DuplicateCNameErr struct {
 
 func (c DuplicateCNameErr) Error() string {
 	pluginName := strings.SplitN(
-		strings.TrimLeft(c.ParentPath, "/"),
+		strings.TrimLeft(c.ParentID, "/"),
 		"/",
 		2,
 	)[0]
 
 	return fmt.Sprintf(
 		"error listing %v: children %v and %v have the same cname of %v. This means that either the %v plugin's API returns duplicate names, or that you need to use a different slash replacement character (see EntryBase#SetSlashReplacementCharar)",
-		c.ParentPath,
+		c.ParentID,
 		c.FirstChildName,
 		c.SecondChildName,
 		c.CName,
@@ -167,7 +167,7 @@ func CachedList(ctx context.Context, g Group) (map[string]Entry, error) {
 
 			if duplicateEntry, ok := searchedEntries[cname]; ok {
 				return nil, DuplicateCNameErr{
-					ParentPath:                      g.id(),
+					ParentID:                        g.id(),
 					FirstChildName:                  duplicateEntry.name(),
 					FirstChildSlashReplacementChar:  duplicateEntry.slashReplacementChar(),
 					SecondChildName:                 entry.name(),
diff --git a/plugin/helpers.go b/plugin/helpers.go
index 61f4b3c..b458f20 100644
--- a/plugin/helpers.go
+++ b/plugin/helpers.go
@@ -56,13 +56,13 @@ func CName(e Entry) string {
 	)
 }
 
-// Path returns the entry's path rooted at Wash's mountpoint. This is what
-// the API consumes. An entry's path is described as
+// ID returns the entry's ID, which is just its path rooted at Wash's mountpoint.
+// An entry's ID is described as
 //     /<plugin_name>/<group1_cname>/<group2_cname>/.../<entry_cname>
 //
 // NOTE: <plugin_name> is really <plugin_cname>. However since <plugin_name>
 // can never contain a '/', <plugin_cname> reduces to <plugin_name>.
-func Path(e Entry) string {
+func ID(e Entry) string {
 	return e.id()
 }
 


Test patch:
diff --git a/api/helpers_test.go b/api/helpers_test.go
index d74aaa5..8d12a14 100644
--- a/api/helpers_test.go
+++ b/api/helpers_test.go
@@ -78,7 +78,7 @@ func (suite *HelpersTestSuite) TestFindEntry() {
 	duplicateFoo := plugin.NewEntry("foo#bar")
 	group.entries = append(group.entries, &duplicateFoo)
 	expectedErr := plugin.DuplicateCNameErr{
-		ParentPath:                      plugin.Path(group),
+		ParentID:                        plugin.ID(group),
 		FirstChildName:                  foo.Name(),
 		FirstChildSlashReplacementChar:  '#',
 		SecondChildName:                 duplicateFoo.Name(),
@@ -112,31 +112,45 @@ func (suite *HelpersTestSuite) TestGetEntryFromPath() {
 	suite.NoError(reg.RegisterPlugin(plug))
 	ctx := context.WithValue(context.Background(), pluginRegistryKey, reg)
 
-	entry, err := getEntryFromPath(ctx, "/")
+	mountpoint := "/mountpoint"
+	ctx = context.WithValue(ctx, mountpointKey, mountpoint)
+
+	_, err := getEntryFromPath(ctx, "relative")
+	suite.Error(relativePathResponse("relative"), err)
+
+	_, err = getEntryFromPath(ctx, "/file")
+	suite.Error(nonWashEntryResponse("/file"), err)
+
+	entry, err := getEntryFromPath(ctx, mountpoint)
+	if suite.Nil(err) {
+		suite.Equal(reg.Name(), plugin.Name(entry))
+	}
+
+	entry, err = getEntryFromPath(ctx, mountpoint+"/")
 	if suite.Nil(err) {
 		suite.Equal(reg.Name(), plugin.Name(entry))
 	}
 
-	entry, err = getEntryFromPath(ctx, "/mine")
+	entry, err = getEntryFromPath(ctx, mountpoint+"/mine")
 	if suite.Nil(err) {
 		suite.Equal(plug.Name(), plugin.Name(entry))
 	}
 
-	_, err = getEntryFromPath(ctx, "/yours")
+	_, err = getEntryFromPath(ctx, mountpoint+"/yours")
 	suite.Error(err)
 
 	file := plugin.NewEntry("a file")
 	file.SetTestID("/mine/a file")
 	plug.On("List", mock.Anything).Return([]plugin.Entry{&file}, nil)
 
-	entry, err = getEntryFromPath(ctx, "/mine/a file")
+	entry, err = getEntryFromPath(ctx, mountpoint+"/mine/a file")
 	if suite.Nil(err) {
 		suite.Equal(file.Name(), plugin.Name(entry))
 	}
 	plug.AssertExpectations(suite.T())
 
 	plug.On("List", mock.Anything).Return([]plugin.Entry{&file}, nil)
-	_, err = getEntryFromPath(ctx, "/mine/a dir")
+	_, err = getEntryFromPath(ctx, mountpoint+"/mine/a dir")
 	suite.Error(err)
 	plug.AssertExpectations(suite.T())
 }
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index 34136f9..ff00157 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -186,7 +186,7 @@ func (suite *CacheTestSuite) TestCachedOp() {
 
 func (suite *CacheTestSuite) TestDuplicateCNameErr() {
 	err := DuplicateCNameErr{
-		ParentPath:                      "/my_plugin/foo",
+		ParentID:                        "/my_plugin/foo",
 		FirstChildName:                  "foo/bar/",
 		FirstChildSlashReplacementChar:  '#',
 		SecondChildName:                 "foo#bar/",
@@ -282,7 +282,7 @@ func (suite *CacheTestSuite) TestCachedListCNameErrors() {
 	_, err := CachedList(ctx, entry)
 	if suite.Error(err) {
 		expectedErr := DuplicateCNameErr{
-			ParentPath:                      "/my_plugin/foo",
+			ParentID:                        "/my_plugin/foo",
 			FirstChildName:                  "foo/bar/",
 			FirstChildSlashReplacementChar:  '#',
 			SecondChildName:                 "foo#bar/",
diff --git a/plugin/helpers_test.go b/plugin/helpers_test.go
index 0f1bf33..ba30da9 100644
--- a/plugin/helpers_test.go
+++ b/plugin/helpers_test.go
@@ -37,11 +37,11 @@ func (suite *HelpersTestSuite) TestCName() {
 	suite.Equal("foo:bar:baz", CName(&e))
 }
 
-func (suite *HelpersTestSuite) TestPath() {
+func (suite *HelpersTestSuite) TestID() {
 	e := NewEntry("foo")
 	e.setID("/foo/bar")
 
-	suite.Equal(Path(&e), "/foo/bar")
+	suite.Equal(ID(&e), "/foo/bar")
 }
 
 type helpersTestsMockEntry struct {