Repository: puppetlabs/wash
Pull request #638 (2019-12-04 20:12:43)

Support block-readable entries
External plugins will be handled in a follow-up PR. We should also file a separate issue for the volume package; I didn't implement BlockReadable for the volume package because it isn't as easy to do as e.g. S3 objects or GCP storage objects.

Supports https://github.com/puppetlabs/wash/issues/619


Patch:
diff --git a/cmd/validate.go b/cmd/validate.go
index f10e699..1662878 100644
--- a/cmd/validate.go
+++ b/cmd/validate.go
@@ -314,7 +314,11 @@ func processEntry(ctx context.Context, pw progress.Writer, wp cmdutil.Pool, e pl
 
 	if plugin.ReadAction().IsSupportedOn(e) {
 		_, cancelFunc, err := withTimeout(ctx, "read", name, func(ctx context.Context) (interface{}, error) {
-			return plugin.Open(ctx, e.(plugin.Readable))
+			data, err := plugin.Read(ctx, e, 0, 1)
+			if err == io.EOF {
+				err = nil
+			}
+			return data, err
 		})
 		if err != nil {
 			errs <- err
diff --git a/fuse/file.go b/fuse/file.go
index ddf6ea1..2ebb1dc 100644
--- a/fuse/file.go
+++ b/fuse/file.go
@@ -39,14 +39,8 @@ func (f *file) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenR
 
 	// Initiate content request and return a channel providing the results.
 	if plugin.ReadAction().IsSupportedOn(updatedEntry) {
-		content, err := plugin.OpenWithAnalytics(ctx, updatedEntry.(plugin.Readable))
-		if err != nil {
-			activity.Warnf(ctx, "FUSE: Open %v errored: %v", f, err)
-			return nil, err
-		}
-
 		activity.Record(ctx, "FUSE: Opened %v", f)
-		fh.r = content
+		fh.r = updatedEntry
 	}
 
 	if plugin.WriteAction().IsSupportedOn(updatedEntry) {
@@ -62,7 +56,8 @@ func (f *file) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenR
 }
 
 type fileHandle struct {
-	r  io.ReaderAt
+	// This is expected to implement either plugin.Readable or plugin.BlockReadable
+	r  plugin.Entry
 	w  plugin.Writable
 	id string
 }
@@ -85,13 +80,12 @@ func (fh fileHandle) Release(ctx context.Context, req *fuse.ReleaseRequest) erro
 }
 
 func (fh fileHandle) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
-	buf := make([]byte, req.Size)
-	n, err := fh.r.ReadAt(buf, req.Offset)
+	data, err := plugin.ReadWithAnalytics(ctx, fh.r, int64(req.Size), req.Offset)
 	if err == io.EOF {
 		err = nil
 	}
-	activity.Record(ctx, "FUSE: Read %v/%v bytes starting at %v from %v: %v", n, req.Size, req.Offset, fh.id, err)
-	resp.Data = buf[:n]
+	activity.Record(ctx, "FUSE: Read %v/%v bytes starting at %v from %v: %v", len(data), req.Size, req.Offset, fh.id, err)
+	resp.Data = data
 	return err
 }
 
diff --git a/plugin/action.go b/plugin/action.go
index 40354ff..a0c86d5 100644
--- a/plugin/action.go
+++ b/plugin/action.go
@@ -1,17 +1,27 @@
 package plugin
 
+type methodSignature int
+
+const (
+	unsupportedSignature methodSignature = iota
+	defaultSignature
+	blockReadableSignature
+)
+
 // Action represents a Wash action.
 type Action struct {
-	Name     string `json:"name"`
-	Protocol string `json:"protocol"`
+	Name                         string `json:"name"`
+	Protocol                     string `json:"protocol"`
+	corePluginEntrySignatureFunc func(e Entry) methodSignature
 }
 
 var actions = make(map[string]Action)
 
-func newAction(name string, protocol string) Action {
+func newAction(name string, protocol string, corePluginEntrySignatureFunc func(e Entry) methodSignature) Action {
 	a := Action{
-		Name:     name,
-		Protocol: protocol,
+		Name:                         name,
+		Protocol:                     protocol,
+		corePluginEntrySignatureFunc: corePluginEntrySignatureFunc,
 	}
 	actions[a.Name] = a
 	return a
@@ -20,22 +30,69 @@ func newAction(name string, protocol string) Action {
 // IsSupportedOn returns true if the action's supported
 // on the specified entry, false otherwise.
 func (a Action) IsSupportedOn(entry Entry) bool {
-	for _, action := range SupportedActionsOf(entry) {
-		if a.Name == action {
-			return true
-		}
-	}
+	return a.signature(entry) != unsupportedSignature
+}
 
-	return false
+func (a Action) signature(entry Entry) methodSignature {
+	switch t := entry.(type) {
+	case externalPlugin:
+		return t.supportedMethods()[a.Name].signature
+	default:
+		return a.corePluginEntrySignatureFunc(entry)
+	}
 }
 
-var listAction = newAction("list", "Parent")
-var readAction = newAction("read", "Readable")
-var streamAction = newAction("stream", "Streamable")
-var writeAction = newAction("write", "Writable")
-var execAction = newAction("exec", "Execable")
-var deleteAction = newAction("delete", "Deletable")
-var signalAction = newAction("signal", "Signalable")
+var listAction = newAction("list", "Parent", func(e Entry) methodSignature {
+	if _, ok := e.(Parent); ok {
+		return defaultSignature
+	}
+	return unsupportedSignature
+})
+
+var readAction = newAction("read", "Readable", func(e Entry) methodSignature {
+	if _, ok := e.(Readable); ok {
+		return defaultSignature
+	}
+	if _, ok := e.(BlockReadable); ok {
+		return blockReadableSignature
+	}
+	return unsupportedSignature
+})
+
+var streamAction = newAction("stream", "Streamable", func(e Entry) methodSignature {
+	if _, ok := e.(Streamable); ok {
+		return defaultSignature
+	}
+	return unsupportedSignature
+})
+
+var writeAction = newAction("write", "Writable", func(e Entry) methodSignature {
+	if _, ok := e.(Writable); ok {
+		return defaultSignature
+	}
+	return unsupportedSignature
+})
+
+var execAction = newAction("exec", "Execable", func(e Entry) methodSignature {
+	if _, ok := e.(Execable); ok {
+		return defaultSignature
+	}
+	return unsupportedSignature
+})
+
+var deleteAction = newAction("delete", "Deletable", func(e Entry) methodSignature {
+	if _, ok := e.(Deletable); ok {
+		return defaultSignature
+	}
+	return unsupportedSignature
+})
+
+var signalAction = newAction("signal", "Signalable", func(e Entry) methodSignature {
+	if _, ok := e.(Signalable); ok {
+		return defaultSignature
+	}
+	return unsupportedSignature
+})
 
 // ListAction represents the list action
 func ListAction() Action {
@@ -87,45 +144,28 @@ func Actions() map[string]Action {
 // SupportedActionsOf returns all of the given
 // entry's supported actions.
 func SupportedActionsOf(entry Entry) []string {
+	var supportedActions []string
+
+	// Iterating over the actions and calling action.signature
+	// results in multiple type switches, which can unnecessarily
+	// slow down list. This implementation does the same thing
+	// but in a single type-switch.
 	switch t := entry.(type) {
 	case externalPlugin:
-		var supportedActions []string
-		for _, method := range t.supportedMethods() {
-			// This ensures that we don't return methods like "metadata"/"schema",
-			// which are not valid Wash actions.
-			if _, ok := actions[method]; ok {
-				supportedActions = append(supportedActions, method)
+		for _, action := range actions {
+			signature := t.supportedMethods()[action.Name].signature
+			if signature != unsupportedSignature {
+				supportedActions = append(supportedActions, action.Name)
 			}
 		}
-		return supportedActions
 	default:
-		actions := make([]string, 0)
-
-		// We could use reflection to simplify this. In fact, a previous version
-		// of the code did do that. The reason we removed it was b/c type assertion's
-		// a lot faster, and the resulting code isn't that bad, if a little verbose.
-		if _, ok := entry.(Parent); ok {
-			actions = append(actions, ListAction().Name)
-		}
-		if _, ok := entry.(Readable); ok {
-			actions = append(actions, ReadAction().Name)
-		}
-		if _, ok := entry.(Streamable); ok {
-			actions = append(actions, StreamAction().Name)
-		}
-		if _, ok := entry.(Writable); ok {
-			actions = append(actions, WriteAction().Name)
-		}
-		if _, ok := entry.(Execable); ok {
-			actions = append(actions, ExecAction().Name)
-		}
-		if _, ok := entry.(Deletable); ok {
-			actions = append(actions, DeleteAction().Name)
-		}
-		if _, ok := entry.(Signalable); ok {
-			actions = append(actions, SignalAction().Name)
+		for _, action := range actions {
+			signature := action.corePluginEntrySignatureFunc(entry)
+			if signature != unsupportedSignature {
+				supportedActions = append(supportedActions, action.Name)
+			}
 		}
-
-		return actions
 	}
+
+	return supportedActions
 }
diff --git a/plugin/analyticsWrappers.go b/plugin/analyticsWrappers.go
index 124254e..ef58dcf 100644
--- a/plugin/analyticsWrappers.go
+++ b/plugin/analyticsWrappers.go
@@ -14,11 +14,11 @@ func ListWithAnalytics(ctx context.Context, p Parent) (*EntryMap, error) {
 	return List(ctx, p)
 }
 
-// OpenWithAnalytics is a wrapper to plugin.Open. Use it when you need to report
-// a 'Read' invocation to analytics. Otherwise, use plugin.Open.
-func OpenWithAnalytics(ctx context.Context, r Readable) (SizedReader, error) {
-	submitMethodInvocation(ctx, r, "Read")
-	return Open(ctx, r)
+// ReadWithAnalytics is a wrapper to plugin.Read. Use it when you need to report
+// a 'Read' invocation to analytics. Otherwise, use plugin.Read.
+func ReadWithAnalytics(ctx context.Context, e Entry, size int64, offset int64) ([]byte, error) {
+	submitMethodInvocation(ctx, e, "Read")
+	return Read(ctx, e, size, offset)
 }
 
 // StreamWithAnalytics is a wrapper to s#Stream. Use it when you need to report a 'Stream'
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index d340d2a..ddc064a 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -233,7 +233,7 @@ func (inst *ec2Instance) checkLatestConsoleOutput(ctx context.Context) (*ec2Inst
 
 	awserr, ok := err.(awserr.Error)
 	if !ok {
-		// Open failed w/ some other error, which should be a
+		// Read failed w/ some other error, which should be a
 		// rare occurrence. Here we reset latestConsoleOutputOnce
 		// so that we check again for the latest console output the
 		// next time List's called, then return an error
@@ -252,7 +252,7 @@ func (inst *ec2Instance) checkLatestConsoleOutput(ctx context.Context) (*ec2Inst
 		return nil, nil
 	}
 
-	// Open failed due to some other AWS-related error. Assume this means
+	// Read failed due to some other AWS-related error. Assume this means
 	// that the instance _does_ have the latest console logs, but something
 	// went wrong with accessing them.
 	inst.hasLatestConsoleOutput = true
diff --git a/plugin/aws/ec2InstanceConsoleOutput.go b/plugin/aws/ec2InstanceConsoleOutput.go
index fabdb20..e426f2e 100644
--- a/plugin/aws/ec2InstanceConsoleOutput.go
+++ b/plugin/aws/ec2InstanceConsoleOutput.go
@@ -1,7 +1,6 @@
 package aws
 
 import (
-	"bytes"
 	"context"
 
 	"github.com/puppetlabs/wash/plugin"
@@ -46,11 +45,11 @@ func (cl *ec2InstanceConsoleOutput) Schema() *plugin.EntrySchema {
 	return plugin.NewEntrySchema(cl, "console.out")
 }
 
-func (cl *ec2InstanceConsoleOutput) Open(ctx context.Context) (plugin.SizedReader, error) {
+func (cl *ec2InstanceConsoleOutput) Read(ctx context.Context) ([]byte, error) {
 	output, err := cl.inst.cachedConsoleOutput(ctx, cl.latest)
 	if err != nil {
 		return nil, err
 	}
 
-	return bytes.NewReader(output.content), nil
+	return output.content, nil
 }
diff --git a/plugin/aws/s3Object.go b/plugin/aws/s3Object.go
index 1757097..8399956 100644
--- a/plugin/aws/s3Object.go
+++ b/plugin/aws/s3Object.go
@@ -2,8 +2,7 @@ package aws
 
 import (
 	"context"
-	"errors"
-	"io"
+	"io/ioutil"
 	"strconv"
 
 	"github.com/puppetlabs/wash/activity"
@@ -72,23 +71,24 @@ func (o *s3Object) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 	return plugin.ToJSONObject(metadata), nil
 }
 
-func (o *s3Object) fetchContent(off int64) (io.ReadCloser, error) {
+func (o *s3Object) Read(ctx context.Context, size int64, offset int64) ([]byte, error) {
 	request := &s3Client.GetObjectInput{
 		Bucket: awsSDK.String(o.bucket),
 		Key:    awsSDK.String(o.key),
-		Range:  awsSDK.String("bytes=" + strconv.FormatInt(off, 10) + "-"),
+		Range:  awsSDK.String("bytes=" + strconv.FormatInt(offset, 10) + "-" + strconv.FormatInt(offset+size, 10)),
 	}
 
 	resp, err := o.client.GetObject(request)
 	if err != nil {
 		return nil, err
 	}
+	defer func() {
+		if err := resp.Body.Close(); err != nil {
+			activity.Record(ctx, "Error closing S3 GetObject response body: %v", err)
+		}
+	}()
 
-	return resp.Body, nil
-}
-
-func (o *s3Object) Open(ctx context.Context) (plugin.SizedReader, error) {
-	return &s3ObjectReader{o: o}, nil
+	return ioutil.ReadAll(resp.Body)
 }
 
 func (o *s3Object) Delete(ctx context.Context) (bool, error) {
@@ -99,46 +99,6 @@ func (o *s3Object) Delete(ctx context.Context) (bool, error) {
 	return true, err
 }
 
-// TODO: Optimize this class later. For now, the simple implementation is
-// enough to get `cat` working for small objects and, for large objects, enough
-// to get it to print something to stdout without having to wait for the entire
-// object to be downloaded.
-//
-// https://github.com/kahing/goofys/blob/master/internal/file.go has some prior
-// art we could use to optimize this.
-type s3ObjectReader struct {
-	o *s3Object
-}
-
-func (s *s3ObjectReader) closeContent(content io.ReadCloser) {
-	if err := content.Close(); err != nil {
-		activity.Record(context.Background(), "aws.s3ObjectReader.ReadAt: failed to close %v's content: %v", s.o.key, err)
-	}
-}
-
-func (s *s3ObjectReader) ReadAt(p []byte, off int64) (int, error) {
-	if off < 0 {
-		return 0, errors.New("aws.s3ObjectReader.ReadAt: negative offset")
-	}
-
-	if off >= s.Size() {
-		return 0, io.EOF
-	}
-
-	content, err := s.o.fetchContent(off)
-	if err != nil {
-		return 0, err
-	}
-	defer s.closeContent(content)
-
-	return content.Read(p)
-}
-
-func (s *s3ObjectReader) Size() int64 {
-	attr := plugin.Attributes(s.o)
-	return int64(attr.Size())
-}
-
 const s3ObjectDescription = `
 This is an S3 object. See the bucket's description for more details on
 why we have this kind of entry.
diff --git a/plugin/cache.go b/plugin/cache.go
index 3bb5d3e..de33bc6 100644
--- a/plugin/cache.go
+++ b/plugin/cache.go
@@ -208,20 +208,53 @@ func cachedList(ctx context.Context, p Parent) (*EntryMap, error) {
 	return cachedEntries.(*EntryMap), nil
 }
 
-// CachedOpen caches a Readable object's Open method.
-// When using the reader returned by this method, use idempotent read operations
-// such as ReadAt or wrap it in a SectionReader. Using Read operations on the cached
-// reader will change it and make subsequent uses of the cached reader invalid.
-func cachedOpen(ctx context.Context, r Readable) (SizedReader, error) {
-	cachedContent, err := cachedDefaultOp(ctx, OpenOp, r, func() (interface{}, error) {
-		return r.Open(ctx)
+// cachedRead caches an entry's Read method
+func cachedRead(ctx context.Context, e Entry) (entryContent, error) {
+	cachedContent, err := cachedDefaultOp(ctx, ReadOp, e, func() (interface{}, error) {
+		switch signature := ReadAction().signature(e); signature {
+		case defaultSignature:
+			// Both external and core plugin entries that have the default Read signature
+			// implement the Readable interface, so we can go ahead and cast directly.
+			r := e.(Readable)
+			rawContent, err := r.Read(ctx)
+			if err != nil {
+				return nil, err
+			}
+			return newEntryContent(rawContent), nil
+		case blockReadableSignature:
+			// Go doesn't allow overloaded functions, so the external plugin entry type
+			// cannot implement both BlockReadable#Read and Readable#Read. Thus, external
+			// plugins implement the BlockReadable interface via a separate blockRead
+			// method.
+			var readFunc blockReadFunc
+			switch t := e.(type) {
+			case externalPlugin:
+				// TODO: external plugin implementation here!
+			case BlockReadable:
+				readFunc = func(ctx context.Context, size int64, offset int64) ([]byte, error) {
+					return t.Read(ctx, size, offset)
+				}
+			default:
+				// We should never hit this code-path
+				panic("attempting to retrieve the content of a non-readable entry")
+			}
+			content := newBlockReadableEntryContent(readFunc)
+			if attr := e.attributes(); attr.HasSize() {
+				content.sz = attr.Size()
+			}
+			return content, nil
+		default:
+			// We should never hit this code-path
+			msg := fmt.Sprintf("unknown signature '%v' for read", signature)
+			panic(msg)
+		}
 	})
 
 	if err != nil {
 		return nil, err
 	}
 
-	return cachedContent.(SizedReader), nil
+	return cachedContent.(entryContent), nil
 }
 
 // cachedMetadata caches an entry's Metadata method
diff --git a/plugin/docker/container-log.go b/plugin/docker/container-log.go
index c6f13dc..3766587 100644
--- a/plugin/docker/container-log.go
+++ b/plugin/docker/container-log.go
@@ -41,7 +41,7 @@ func (clf *containerLogFile) Schema() *plugin.EntrySchema {
 	return plugin.NewEntrySchema(clf, "log").IsSingleton()
 }
 
-func (clf *containerLogFile) Open(ctx context.Context) (plugin.SizedReader, error) {
+func (clf *containerLogFile) Read(ctx context.Context) ([]byte, error) {
 	opts := types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}
 	rdr, err := clf.client.ContainerLogs(ctx, clf.containerName, opts)
 	if err != nil {
@@ -62,7 +62,7 @@ func (clf *containerLogFile) Open(ctx context.Context) (plugin.SizedReader, erro
 	}
 	activity.Record(ctx, "Read %v bytes of %v log", n, clf.containerName)
 
-	return bytes.NewReader(buf.Bytes()), nil
+	return buf.Bytes(), nil
 }
 
 func (clf *containerLogFile) Stream(ctx context.Context) (io.ReadCloser, error) {
diff --git a/plugin/docker/volume.go b/plugin/docker/volume.go
index f8bd04a..94fa701 100644
--- a/plugin/docker/volume.go
+++ b/plugin/docker/volume.go
@@ -173,7 +173,7 @@ func (v *volume) VolumeList(ctx context.Context, path string) (volpkg.DirMap, er
 	return volpkg.StatParseAll(bytes.NewReader(output), mountpoint, path, maxdepth)
 }
 
-func (v *volume) VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, error) {
+func (v *volume) VolumeRead(ctx context.Context, path string) ([]byte, error) {
 	// Create a container that mounts a volume and waits. Use it to download a file.
 	cid, err := v.createContainer(ctx, []string{"sleep", "60"})
 	if err != nil {
@@ -211,7 +211,7 @@ func (v *volume) VolumeOpen(ctx context.Context, path string) (plugin.SizedReade
 	if err != nil {
 		return nil, err
 	}
-	return bytes.NewReader(bits), nil
+	return bits, nil
 }
 
 func (v *volume) VolumeStream(ctx context.Context, path string) (io.ReadCloser, error) {
diff --git a/plugin/entryBase.go b/plugin/entryBase.go
index 26a6c99..361baa2 100644
--- a/plugin/entryBase.go
+++ b/plugin/entryBase.go
@@ -13,13 +13,13 @@ type defaultOpCode int8
 const (
 	// ListOp represents Parent#List
 	ListOp defaultOpCode = iota
-	// OpenOp represents Readable#Open
-	OpenOp
+	// ReadOp represents Readable/BlockReadable#Read
+	ReadOp
 	// MetadataOp represents Entry#Metadata
 	MetadataOp
 )
 
-var defaultOpCodeToNameMap = [3]string{"List", "Open", "Metadata"}
+var defaultOpCodeToNameMap = [3]string{"List", "Read", "Metadata"}
 
 /*
 EntryBase implements Entry, making it easy to create new entries.
diff --git a/plugin/entryContent.go b/plugin/entryContent.go
new file mode 100644
index 0000000..f87d820
--- /dev/null
+++ b/plugin/entryContent.go
@@ -0,0 +1,71 @@
+package plugin
+
+import (
+	"context"
+	"io"
+)
+
+// entryContent is the cached result of a Read invocation
+type entryContent interface {
+	read(context.Context, int64, int64) ([]byte, error)
+	size() uint64
+}
+
+// entryContentImpl is the default implementation of entryContent,
+// meant for Readable entries
+type entryContentImpl struct {
+	content []byte
+}
+
+func newEntryContent(content []byte) *entryContentImpl {
+	return &entryContentImpl{
+		content: content,
+	}
+}
+
+func (c *entryContentImpl) read(_ context.Context, size int64, offset int64) (data []byte, err error) {
+	data = []byte{}
+	contentSize := int64(len(c.content))
+	if offset >= contentSize {
+		err = io.EOF
+		return
+	}
+	endIx := offset + size
+	if contentSize < endIx {
+		endIx = contentSize
+		err = io.EOF
+	}
+	data = c.content[offset:endIx]
+	return
+}
+
+func (c *entryContentImpl) size() uint64 {
+	return uint64(len(c.content))
+}
+
+type blockReadFunc = func(context.Context, int64, int64) ([]byte, error)
+
+// blockReadableEntryContent is the implementation of entryContent that's
+// meant for BlockReadable entries. For now, it doesn't cache any content.
+type blockReadableEntryContent struct {
+	readFunc blockReadFunc
+	sz       uint64
+}
+
+func newBlockReadableEntryContent(readFunc blockReadFunc) *blockReadableEntryContent {
+	return &blockReadableEntryContent{
+		readFunc: readFunc,
+	}
+}
+
+// Note that we don't need to check the offset/size because if the size attribute
+// was set, then plugin.Read already did that validation. If the size attribute
+// wasn't set, then it is the responsibility of the plugin's API to raise the error
+// for us.
+func (c *blockReadableEntryContent) read(ctx context.Context, size int64, offset int64) ([]byte, error) {
+	return c.readFunc(ctx, size, offset)
+}
+
+func (c *blockReadableEntryContent) size() uint64 {
+	return c.sz
+}
diff --git a/plugin/externalPluginEntry.go b/plugin/externalPluginEntry.go
index aa7e979..a72b118 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/externalPluginEntry.go
@@ -1,7 +1,6 @@
 package plugin
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
@@ -20,7 +19,7 @@ import (
 
 type externalPlugin interface {
 	Entry
-	supportedMethods() []string
+	supportedMethods() map[string]methodInfo
 	// Entry#Schema's type-signature only makes sense for core plugins
 	// since core plugin schemas do not require any error-prone API
 	// calls. External plugin schemas can be prefetched (no error)
@@ -54,12 +53,14 @@ type decodedExternalPluginEntry struct {
 
 const entryMethodTypeError = "each method must be a string or tuple [<method>, <result>], not %v"
 
-func mungeToMethods(input []interface{}) (map[string]interface{}, error) {
-	methods := make(map[string]interface{})
+func mungeToMethods(input []interface{}) (map[string]methodInfo, error) {
+	methods := make(map[string]methodInfo)
 	for _, val := range input {
 		switch data := val.(type) {
 		case string:
-			methods[data] = nil
+			methods[data] = methodInfo{
+				signature: defaultSignature,
+			}
 		case []interface{}:
 			if len(data) != 2 {
 				return nil, fmt.Errorf(entryMethodTypeError, data)
@@ -68,7 +69,10 @@ func mungeToMethods(input []interface{}) (map[string]interface{}, error) {
 			if !ok {
 				return nil, fmt.Errorf(entryMethodTypeError, data)
 			}
-			methods[name] = data[1]
+			methods[name] = methodInfo{
+				signature: defaultSignature,
+				result:    data[1],
+			}
 		default:
 			return nil, fmt.Errorf(entryMethodTypeError, data)
 		}
@@ -95,7 +99,7 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 	// Thus, it is reasonable for us to expect (and require) every entry to implement schema
 	// if the root implements it. It is also reasonable for us to expect (and require)
 	// every entry to _not_ implement schema if the root does not implement it.
-	if result, ok := methods["schema"]; ok {
+	if info, ok := methods["schema"]; ok {
 		if len(e.TypeID) == 0 {
 			return nil, fmt.Errorf("entry %v implements schema, but no type ID was provided", e.Name)
 		}
@@ -103,7 +107,7 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 			return nil, fmt.Errorf("entry %v (%v) implements schema, but the plugin root doesn't", e.Name, e.TypeID)
 		}
 		// schemaKnown || isRoot
-		if !isRoot && result != nil {
+		if !isRoot && info.result != nil {
 			return nil, fmt.Errorf(
 				"entry %v (%v) prefetched its schema. Only plugin roots support schema prefetching",
 				e.Name,
@@ -120,7 +124,7 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 
 	// If read content is static, it's likely it's not coming from a source that separately provides
 	// the size of that data. If not provided, update it since we know what it is.
-	if content, ok := methods["read"].(string); ok && !e.Attributes.HasSize() {
+	if content, ok := methods["read"].result.(string); ok && !e.Attributes.HasSize() {
 		e.Attributes.SetSize(uint64(len(content)))
 	}
 
@@ -146,18 +150,23 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 	}
 
 	// If some data originated from the parent via list, mark as prefetched.
-	if entry.methods["list"] != nil || entry.methods["read"] != nil {
+	if entry.methods["list"].result != nil || entry.methods["read"].result != nil {
 		entry.Prefetched()
 	}
 
 	return entry, nil
 }
 
+type methodInfo struct {
+	signature methodSignature
+	result    interface{}
+}
+
 // externalPluginEntry represents an external plugin entry
 type externalPluginEntry struct {
 	EntryBase
 	script    externalPluginScript
-	methods   map[string]interface{}
+	methods   map[string]methodInfo
 	state     string
 	rawTypeID string
 	// schemaKnown is set by the root. We use it to enforce the invariant
@@ -174,7 +183,7 @@ func (e *externalPluginEntry) setCacheTTLs(ttls decodedCacheTTLs) {
 		e.SetTTLOf(ListOp, ttls.List*time.Second)
 	}
 	if ttls.Read != 0 {
-		e.SetTTLOf(OpenOp, ttls.Read*time.Second)
+		e.SetTTLOf(ReadOp, ttls.Read*time.Second)
 	}
 	if ttls.Metadata != 0 {
 		e.SetTTLOf(MetadataOp, ttls.Metadata*time.Second)
@@ -188,12 +197,8 @@ func (e *externalPluginEntry) implements(method string) bool {
 	return ok
 }
 
-func (e *externalPluginEntry) supportedMethods() []string {
-	keys := make([]string, 0, len(e.methods))
-	for k := range e.methods {
-		keys = append(keys, k)
-	}
-	return keys
+func (e *externalPluginEntry) supportedMethods() map[string]methodInfo {
+	return e.methods
 }
 
 func (e *externalPluginEntry) ChildSchemas() []*EntrySchema {
@@ -244,7 +249,10 @@ func (e *externalPluginEntry) schema() (*EntrySchema, error) {
 		// is a mismatch. Hopefully this should never happen.
 		es, _ := graph.Get(TypeID(e))
 		schemaMethods := es.(entrySchema).Actions
-		instanceMethods := e.supportedMethods()
+		instanceMethods := []string{}
+		for method := range e.supportedMethods() {
+			instanceMethods = append(instanceMethods, method)
+		}
 		sort.Strings(schemaMethods)
 		sort.Strings(instanceMethods)
 		mismatchErr := fmt.Errorf(
@@ -305,7 +313,7 @@ const listFormat = "[{\"name\":\"entry1\",\"methods\":[\"list\"]},{\"name\":\"en
 
 func (e *externalPluginEntry) List(ctx context.Context) ([]Entry, error) {
 	var decodedEntries []decodedExternalPluginEntry
-	if impl, ok := e.methods["list"]; ok && impl != nil {
+	if impl := e.methods["list"].result; impl != nil {
 		// Entry statically implements list. Construct new entries based on that rather than invoking the script.
 		bits, err := json.Marshal(impl)
 		if err != nil {
@@ -339,10 +347,10 @@ func (e *externalPluginEntry) List(ctx context.Context) ([]Entry, error) {
 	return entries, nil
 }
 
-func (e *externalPluginEntry) Open(ctx context.Context) (SizedReader, error) {
-	if impl, ok := e.methods["read"]; ok && impl != nil {
+func (e *externalPluginEntry) Read(ctx context.Context) ([]byte, error) {
+	if impl := e.methods["read"].result; impl != nil {
 		if content, ok := impl.(string); ok {
-			return strings.NewReader(content), nil
+			return []byte(content), nil
 		}
 		return nil, fmt.Errorf("Read method must provide a string, not %v", impl)
 	}
@@ -351,9 +359,11 @@ func (e *externalPluginEntry) Open(ctx context.Context) (SizedReader, error) {
 	if err != nil {
 		return nil, err
 	}
-	return bytes.NewReader(inv.stdout.Bytes()), nil
+	return inv.stdout.Bytes(), nil
 }
 
+// TODO: Implement blockRead
+
 func (e *externalPluginEntry) Metadata(ctx context.Context) (JSONObject, error) {
 	if !e.implements("metadata") {
 		// The entry does not override the "Metadata" method so invoke
diff --git a/plugin/externalPluginRoot.go b/plugin/externalPluginRoot.go
index 5d497ba..693cc55 100644
--- a/plugin/externalPluginRoot.go
+++ b/plugin/externalPluginRoot.go
@@ -71,7 +71,7 @@ it's safe to omit name from the response to 'init'`, r.script.Path()))
 	r.externalPluginEntry.script = script
 
 	// Fill in the schema graph if provided
-	if rawSchema := r.methods["schema"]; rawSchema != nil {
+	if rawSchema := r.methods["schema"].result; rawSchema != nil {
 		marshalledSchema, err := json.Marshal(rawSchema)
 		if err != nil {
 			panic(fmt.Sprintf("Error remarshaling previously unmarshaled data: %v", err))
diff --git a/plugin/gcp/cloudLogFile.go b/plugin/gcp/cloudLogFile.go
index 85d7dda..3b35808 100644
--- a/plugin/gcp/cloudLogFile.go
+++ b/plugin/gcp/cloudLogFile.go
@@ -10,7 +10,6 @@ import (
 
 	"github.com/puppetlabs/wash/activity"
 	cmdutil "github.com/puppetlabs/wash/cmd/util"
-	"github.com/puppetlabs/wash/plugin"
 	"google.golang.org/api/logging/v2"
 	"google.golang.org/api/option"
 )
@@ -57,7 +56,7 @@ func newCloudLogFile(
 	return clf, nil
 }
 
-func (clf *cloudLogFile) Open(ctx context.Context) (plugin.SizedReader, error) {
+func (clf *cloudLogFile) Read(ctx context.Context) ([]byte, error) {
 	// 1000 matches gcloud's upper limit for fetching logs
 	entries, err := clf.fetchEntries(ctx, 1000, "")
 	if err != nil {
@@ -72,7 +71,7 @@ func (clf *cloudLogFile) Open(ctx context.Context) (plugin.SizedReader, error) {
 	}
 	activity.Record(ctx, "HEADERS: %v, ENTRIES: %v", headers, entries)
 	table := cmdutil.NewTableWithHeaders(headers, entries)
-	return strings.NewReader(table.Format()), nil
+	return []byte(table.Format()), nil
 }
 
 // Note that we use afterTimestamp instead of pageToken because the latter doesn't work well with
diff --git a/plugin/gcp/computeInstConsoleOutput.go b/plugin/gcp/computeInstConsoleOutput.go
index 5c06e14..2913dc3 100644
--- a/plugin/gcp/computeInstConsoleOutput.go
+++ b/plugin/gcp/computeInstConsoleOutput.go
@@ -2,7 +2,6 @@ package gcp
 
 import (
 	"context"
-	"strings"
 
 	"github.com/puppetlabs/wash/activity"
 	"github.com/puppetlabs/wash/plugin"
@@ -27,7 +26,7 @@ func (cl *computeInstanceConsoleOutput) Schema() *plugin.EntrySchema {
 	return plugin.NewEntrySchema(cl, "console.out").IsSingleton()
 }
 
-func (cl *computeInstanceConsoleOutput) Open(ctx context.Context) (plugin.SizedReader, error) {
+func (cl *computeInstanceConsoleOutput) Read(ctx context.Context) ([]byte, error) {
 	zone := getZone(cl.instance)
 	activity.Record(ctx,
 		"Getting output for instance %v in project %v, zone %v", cl.instance.Name, cl.service.projectID, zone)
@@ -37,5 +36,5 @@ func (cl *computeInstanceConsoleOutput) Open(ctx context.Context) (plugin.SizedR
 		return nil, err
 	}
 
-	return strings.NewReader(outputResp.Contents), nil
+	return []byte(outputResp.Contents), nil
 }
diff --git a/plugin/gcp/firestoreDocument.go b/plugin/gcp/firestoreDocument.go
index 4e83edd..1ebfdb0 100644
--- a/plugin/gcp/firestoreDocument.go
+++ b/plugin/gcp/firestoreDocument.go
@@ -1,7 +1,6 @@
 package gcp
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
 	"time"
@@ -101,8 +100,8 @@ func newFirestoreDocumentDataJSON(data map[string]interface{}) (*firestoreDocume
 	return dataEntry, nil
 }
 
-func (data *firestoreDocumentDataJSON) Open(ctx context.Context) (plugin.SizedReader, error) {
-	return bytes.NewReader(data.bytes), nil
+func (data *firestoreDocumentDataJSON) Read(ctx context.Context) ([]byte, error) {
+	return data.bytes, nil
 }
 
 func (data *firestoreDocumentDataJSON) Schema() *plugin.EntrySchema {
diff --git a/plugin/gcp/storageObject.go b/plugin/gcp/storageObject.go
index 41caba4..c6df4f5 100644
--- a/plugin/gcp/storageObject.go
+++ b/plugin/gcp/storageObject.go
@@ -2,8 +2,10 @@ package gcp
 
 import (
 	"context"
+	"io/ioutil"
 
 	"cloud.google.com/go/storage"
+	"github.com/puppetlabs/wash/activity"
 	"github.com/puppetlabs/wash/plugin"
 )
 
@@ -30,8 +32,17 @@ func (s *storageObject) Schema() *plugin.EntrySchema {
 		SetMetaAttributeSchema(storage.ObjectAttrs{})
 }
 
-func (s *storageObject) Open(ctx context.Context) (plugin.SizedReader, error) {
-	return &objectReader{ObjectHandle: s.ObjectHandle, size: int64(s.Attributes().Size())}, nil
+func (s *storageObject) Read(ctx context.Context, size int64, offset int64) ([]byte, error) {
+	rdr, err := s.NewRangeReader(context.Background(), offset, int64(size))
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		if err := rdr.Close(); err != nil {
+			activity.Record(ctx, "Error closing GCP storage object range reader: %v", err)
+		}
+	}()
+	return ioutil.ReadAll(rdr)
 }
 
 func (s *storageObject) Delete(ctx context.Context) (bool, error) {
@@ -39,26 +50,6 @@ func (s *storageObject) Delete(ctx context.Context) (bool, error) {
 	return true, err
 }
 
-type objectReader struct {
-	*storage.ObjectHandle
-	size int64
-}
-
-// This is a fairly inefficient implementation that always reads exactly what's asked for.
-// However most uses will probably read the content once and buffer it themselves.
-// TODO: buffer some so that we don't read lots of small chunks.
-func (r *objectReader) ReadAt(p []byte, off int64) (int, error) {
-	rdr, err := r.NewRangeReader(context.Background(), off, int64(len(p)))
-	if err != nil {
-		return 0, err
-	}
-	return rdr.Read(p)
-}
-
-func (r *objectReader) Size() int64 {
-	return r.size
-}
-
 const storageObjectDescription = `
 This is a Storage object. See the bucket's description for more details
 on why we have this kind of entry.
diff --git a/plugin/kubernetes/container.go b/plugin/kubernetes/container.go
index 3a8a0df..c4d5446 100644
--- a/plugin/kubernetes/container.go
+++ b/plugin/kubernetes/container.go
@@ -59,7 +59,7 @@ func (c *container) Schema() *plugin.EntrySchema {
 		SetMetaAttributeSchema(corev1.Container{})
 }
 
-func (c *container) Open(ctx context.Context) (plugin.SizedReader, error) {
+func (c *container) Read(ctx context.Context) ([]byte, error) {
 	logOptions := corev1.PodLogOptions{
 		Container: c.Name(),
 	}
@@ -75,7 +75,7 @@ func (c *container) Open(ctx context.Context) (plugin.SizedReader, error) {
 	}
 	activity.Record(ctx, "Read %v bytes of %v log", n, c.Name())
 
-	return bytes.NewReader(buf.Bytes()), nil
+	return buf.Bytes(), nil
 }
 
 func (c *container) Stream(ctx context.Context) (io.ReadCloser, error) {
diff --git a/plugin/kubernetes/pvc.go b/plugin/kubernetes/pvc.go
index e93ff3d..2b48419 100644
--- a/plugin/kubernetes/pvc.go
+++ b/plugin/kubernetes/pvc.go
@@ -191,12 +191,12 @@ func (v *pvc) VolumeList(ctx context.Context, path string) (volume.DirMap, error
 	return volume.StatParseAll(bytes.NewReader(output), mountpoint, path, maxdepth)
 }
 
-func (v *pvc) VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, error) {
+func (v *pvc) VolumeRead(ctx context.Context, path string) ([]byte, error) {
 	output, err := v.runInTemporaryPod(ctx, []string{"cat", mountpoint + path})
 	if err != nil {
 		return nil, err
 	}
-	return bytes.NewReader(output), nil
+	return output, nil
 }
 
 func (v *pvc) VolumeStream(ctx context.Context, path string) (io.ReadCloser, error) {
diff --git a/plugin/metadataJSON.go b/plugin/metadataJSON.go
index a058ec6..34de027 100644
--- a/plugin/metadataJSON.go
+++ b/plugin/metadataJSON.go
@@ -1,7 +1,6 @@
 package plugin
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
 )
@@ -22,12 +21,12 @@ func NewMetadataJSONFile(ctx context.Context, other Entry) (*MetadataJSONFile, e
 
 	if other.getTTLOf(MetadataOp) < 0 {
 		// Content is presumably easy to get, so use it to determine size.
-		content, err := meta.Open(ctx)
+		content, err := meta.Read(ctx)
 		if err != nil {
 			return nil, err
 		}
 
-		meta.Attributes().SetSize(uint64(content.Size()))
+		meta.Attributes().SetSize(uint64(len(content)))
 	}
 
 	return meta, nil
@@ -40,8 +39,8 @@ func (m *MetadataJSONFile) Schema() *EntrySchema {
 		IsSingleton()
 }
 
-// Open returns the metadata of the `other` entry as its content.
-func (m *MetadataJSONFile) Open(ctx context.Context) (SizedReader, error) {
+// Read returns the metadata of the `other` entry as its content.
+func (m *MetadataJSONFile) Read(ctx context.Context) ([]byte, error) {
 	meta, err := Metadata(ctx, m.other)
 	if err != nil {
 		return nil, err
@@ -52,7 +51,7 @@ func (m *MetadataJSONFile) Open(ctx context.Context) (SizedReader, error) {
 		return nil, err
 	}
 
-	return bytes.NewReader(prettyMeta), nil
+	return prettyMeta, nil
 }
 
 const metadataJSONDescription = `
diff --git a/plugin/methodWrappers.go b/plugin/methodWrappers.go
index 16ef868..d20127a 100644
--- a/plugin/methodWrappers.go
+++ b/plugin/methodWrappers.go
@@ -6,6 +6,8 @@ import (
 	"io"
 	"strings"
 	"time"
+
+	"github.com/puppetlabs/wash/activity"
 )
 
 // InvalidInputErr indicates that the method invocation received invalid
@@ -102,9 +104,9 @@ func Attributes(e Entry) EntryAttributes {
 		// We have no way to preserve this on the entry, and it likely wouldn't help because we often
 		// recreate the entry to ensure we have an accurate representation. So when the cache expires
 		// we revert to stating the size is unknown until the next read operation.
-		if val, _ := cache.Get(defaultOpCodeToNameMap[OpenOp], e.id()); val != nil {
-			rdr := val.(SizedReader)
-			attr.SetSize(uint64(rdr.Size()))
+		if val, _ := cache.Get(defaultOpCodeToNameMap[ReadOp], e.id()); val != nil {
+			content := val.(entryContent)
+			attr.SetSize(content.size())
 		}
 	}
 	return attr
@@ -129,14 +131,53 @@ func List(ctx context.Context, p Parent) (*EntryMap, error) {
 	return cachedList(ctx, p)
 }
 
-// Open reads the entry's content. Note that Open's results could be cached. Thus, when
-// using the reader returned by this method, use idempotent read operations such as ReadAt
-// or wrap it in a SectionReader. Using Read operations on the cached reader will change it
-// and make subsequent uses of the cached reader invalid.
+// Read reads up to size bits of the entry's content starting at the given offset.
+// It will panic if the entry does not support the read action. Callers can use
+// len(data) to check the amount of data that was actually read.
+//
+// If the offset is >= to the available content size, then data != nil, len(data) == 0,
+// and err == io.EOF. Otherwise if len(data) < size, then err == io.EOF.
 //
-// TODO: Could we change this to Read? E.g. plugin.Read.
-func Open(ctx context.Context, r Readable) (SizedReader, error) {
-	return cachedOpen(ctx, r)
+// Note that Read is thread-safe.
+func Read(ctx context.Context, e Entry, size int64, offset int64) (data []byte, err error) {
+	if !ReadAction().IsSupportedOn(e) {
+		panic("plugin.Read called on a non-readable entry")
+	}
+	if size < 0 {
+		return nil, fmt.Errorf("called with a negative size %v", size)
+	}
+	if offset < 0 {
+		return nil, fmt.Errorf("called with a negative offset %v", offset)
+	}
+	data = []byte{}
+	inputSize := size
+	if attr := e.attributes(); attr.HasSize() {
+		contentSize := int64(attr.Size())
+		if offset >= contentSize {
+			err = io.EOF
+			return
+		}
+		minSize := size + offset
+		if contentSize < minSize {
+			size = contentSize
+			err = io.EOF
+		}
+	} else if ReadAction().signature(e) == blockReadableSignature {
+		activity.Warnf(ctx, "size attribute not set for block-readable entry %v", e.id())
+	}
+	content, contentErr := cachedRead(ctx, e)
+	if contentErr != nil {
+		err = contentErr
+		return
+	}
+	data, readErr := content.read(ctx, size, offset)
+	if readErr != nil {
+		err = readErr
+	}
+	if actualSize := int64(len(data)); actualSize > size {
+		return nil, fmt.Errorf("requested %v bytes (input was %v), but plugin's API returned %v bytes", size, inputSize, actualSize)
+	}
+	return
 }
 
 // Metadata returns the entry's metadata. Note that Metadata's results could be cached.
diff --git a/plugin/types.go b/plugin/types.go
index e5eddad..b548dc7 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -189,10 +189,16 @@ type SizedReader interface {
 	Size() int64
 }
 
-// Readable is an entry that has a fixed amount of content we can read.
+// BlockReadable is an entry with content that can be read in blocks
+type BlockReadable interface {
+	Entry
+	Read(ctx context.Context, size int64, offset int64) ([]byte, error)
+}
+
+// Readable is an entry with content that can be read
 type Readable interface {
 	Entry
-	Open(context.Context) (SizedReader, error)
+	Read(context.Context) ([]byte, error)
 }
 
 // Writable is an entry that can write data directly to the entry. It mirrors
diff --git a/volume/core.go b/volume/core.go
index b28a19e..fc906b4 100644
--- a/volume/core.go
+++ b/volume/core.go
@@ -28,7 +28,7 @@ type Interface interface {
 	// points to a nil Dir, it is assumed to be unexplored.
 	VolumeList(ctx context.Context, path string) (DirMap, error)
 	// Accepts a path and returns the content associated with that path.
-	VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, error)
+	VolumeRead(ctx context.Context, path string) ([]byte, error)
 	// Accepts a path and streams updates to the content associated with that path.
 	VolumeStream(ctx context.Context, path string) (io.ReadCloser, error)
 	// Deletes the volume node at the specified path. Mirrors plugin.Deletable#Delete
diff --git a/volume/file.go b/volume/file.go
index 55b9827..331af7a 100644
--- a/volume/file.go
+++ b/volume/file.go
@@ -24,7 +24,7 @@ func newFile(name string, attr plugin.EntryAttributes, impl Interface, path stri
 	vf.impl = impl
 	vf.path = path
 	vf.SetAttributes(attr)
-	vf.SetTTLOf(plugin.OpenOp, 60*time.Second)
+	vf.SetTTLOf(plugin.ReadOp, 60*time.Second)
 
 	return vf
 }
@@ -33,9 +33,9 @@ func (v *file) Schema() *plugin.EntrySchema {
 	return plugin.NewEntrySchema(v, "file").SetDescription(fileDescription)
 }
 
-// Open returns the content of the file as a SizedReader.
-func (v *file) Open(ctx context.Context) (plugin.SizedReader, error) {
-	return v.impl.VolumeOpen(ctx, v.path)
+// Read reads the content of the file
+func (v *file) Read(ctx context.Context) ([]byte, error) {
+	return v.impl.VolumeRead(ctx, v.path)
 }
 
 func (v *file) Stream(ctx context.Context) (io.ReadCloser, error) {
diff --git a/volume/fs.go b/volume/fs.go
index f60e4ed..c67464a 100644
--- a/volume/fs.go
+++ b/volume/fs.go
@@ -114,8 +114,8 @@ func (d *FS) VolumeList(ctx context.Context, path string) (DirMap, error) {
 	return StatParseAll(buf, RootPath, path, d.maxdepth)
 }
 
-// VolumeOpen satisfies the Interface required by List to read file contents.
-func (d *FS) VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, error) {
+// VolumeRead satisfies the Interface required by List to read file contents.
+func (d *FS) VolumeRead(ctx context.Context, path string) ([]byte, error) {
 	activity.Record(ctx, "Reading %v on %v", path, plugin.ID(d.executor))
 
 	// Don't use Tty when outputting file content because it may convert LF to CRLF.
@@ -124,7 +124,7 @@ func (d *FS) VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, e
 		activity.Record(ctx, "Exec error running 'cat %v' in VolumeOpen: %v", path, err)
 		return nil, err
 	}
-	return bytes.NewReader(buf.Bytes()), nil
+	return buf.Bytes(), nil
 }
 
 // VolumeStream satisfies the Interface required by List to stream file contents.


Test patch:
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index e6fe479..a9e0066 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"regexp"
-	"strings"
 	"testing"
 	"time"
 
@@ -181,6 +180,11 @@ func (e *cacheTestsMockEntry) Metadata(ctx context.Context) (JSONObject, error)
 	return args.Get(0).(JSONObject), args.Error(1)
 }
 
+func (e *cacheTestsMockEntry) Read(ctx context.Context) ([]byte, error) {
+	args := e.Called(ctx)
+	return args.Get(0).([]byte), args.Error(1)
+}
+
 type cachedDefaultOpFunc func(ctx context.Context, e Entry) (interface{}, error)
 
 func (suite *CacheTestSuite) TestCachedOp() {
@@ -369,13 +373,95 @@ func (suite *CacheTestSuite) TestCachedListSetEntryID() {
 	}
 }
 
-func (suite *CacheTestSuite) TestCachedOpen() {
-	mockReader := strings.NewReader("foo")
-	suite.testCachedDefaultOp(OpenOp, "Open", mockReader, mockReader, func(ctx context.Context, e Entry) (interface{}, error) {
-		return cachedOpen(ctx, e.(Readable))
+func (suite *CacheTestSuite) TestCachedRead_DefaultOp() {
+	// This also tests a successful read of a ReadableCorePluginEntry
+	mockRawContent := []byte("some raw content")
+	mockContent := newEntryContent(mockRawContent)
+	suite.testCachedDefaultOp(ReadOp, "Read", mockRawContent, mockContent, func(ctx context.Context, e Entry) (interface{}, error) {
+		return cachedRead(ctx, e)
 	})
 }
 
+func (suite *CacheTestSuite) TestCachedRead_ReadableCorePluginEntry_ErroredRead() {
+	entry := newCacheTestsMockEntry("foo")
+	entry.DisableDefaultCaching()
+	entry.SetTestID("/foo")
+
+	ctx := context.Background()
+	expectedErr := fmt.Errorf("an error")
+	entry.On("Read", ctx).Return([]byte{}, expectedErr)
+
+	_, err := cachedRead(ctx, entry)
+	suite.Equal(expectedErr, err)
+}
+
+func (suite *CacheTestSuite) TestCachedRead_ReadableExternalPluginEntry_SuccessfulRead() {
+	entry := &externalPluginEntry{
+		EntryBase: NewEntry("foo"),
+		methods: map[string]methodInfo{
+			"read": methodInfo{signature: defaultSignature, result: "some raw content"},
+		},
+	}
+	entry.DisableDefaultCaching()
+	entry.SetTestID("/foo")
+
+	expectedContent := newEntryContent([]byte("some raw content"))
+
+	actualContent, err := cachedRead(context.Background(), entry)
+	if suite.NoError(err) {
+		suite.Equal(expectedContent, actualContent)
+	}
+}
+
+func (suite *CacheTestSuite) TestCachedRead_ReadableExternalPluginEntry_ErroredRead() {
+	entry := &externalPluginEntry{
+		EntryBase: NewEntry("foo"),
+		methods: map[string]methodInfo{
+			"read": methodInfo{signature: defaultSignature, result: []byte("invalid content")},
+		},
+	}
+	entry.DisableDefaultCaching()
+	entry.SetTestID("/foo")
+
+	_, err := cachedRead(context.Background(), entry)
+	suite.Regexp("Read.*string", err.Error())
+}
+
+type cacheTestsMockBlockReadableEntry struct {
+	*cacheTestsMockEntry
+}
+
+func (m *cacheTestsMockBlockReadableEntry) Read(ctx context.Context, size int64, offset int64) ([]byte, error) {
+	args := m.Called(ctx, size, offset)
+	return args.Get(0).([]byte), args.Error(1)
+}
+
+func (suite *CacheTestSuite) TestCachedRead_BlockReadableCorePluginEntry() {
+	expectedRawContent := []byte("some raw content")
+
+	entry := &cacheTestsMockBlockReadableEntry{
+		cacheTestsMockEntry: newCacheTestsMockEntry("foo"),
+	}
+	entry.DisableDefaultCaching()
+	entry.SetTestID("/foo")
+	entry.Attributes().SetSize(uint64(len(expectedRawContent)))
+
+	ctx := context.Background()
+	entry.On("Read", ctx, int64(10), int64(0)).Return(expectedRawContent, nil).Once()
+
+	content, err := cachedRead(ctx, entry)
+	suite.Equal(entry.Attributes().Size(), content.size())
+	if suite.NoError(err) {
+		actualRawContent, err := content.read(ctx, 10, 0)
+		if suite.NoError(err) {
+			suite.Equal(expectedRawContent, actualRawContent)
+			entry.AssertExpectations(suite.T())
+		}
+	}
+}
+
+// TODO: Add block readable external plugin entry test case
+
 func (suite *CacheTestSuite) TestCachedMetadata() {
 	mockJSONObject := JSONObject{"foo": "bar"}
 	suite.testCachedDefaultOp(MetadataOp, "Metadata", mockJSONObject, mockJSONObject, func(ctx context.Context, e Entry) (interface{}, error) {
diff --git a/plugin/entryBase_test.go b/plugin/entryBase_test.go
index c8549b6..7349c41 100644
--- a/plugin/entryBase_test.go
+++ b/plugin/entryBase_test.go
@@ -39,7 +39,7 @@ func (suite *EntryBaseTestSuite) TestNewEntry() {
 
 	suite.Equal("foo", e.Name())
 	suite.assertOpTTL(e, ListOp, "List", 15*time.Second)
-	suite.assertOpTTL(e, OpenOp, "Open", 15*time.Second)
+	suite.assertOpTTL(e, ReadOp, "Read", 15*time.Second)
 	suite.assertOpTTL(e, MetadataOp, "Metadata", 15*time.Second)
 
 	e.setID("/foo")
@@ -52,7 +52,7 @@ func (suite *EntryBaseTestSuite) TestNewEntry() {
 	suite.assertOpTTL(e, ListOp, "List", -1)
 
 	e.DisableDefaultCaching()
-	suite.assertOpTTL(e, OpenOp, "Open", -1)
+	suite.assertOpTTL(e, ReadOp, "Read", -1)
 	suite.assertOpTTL(e, MetadataOp, "Metadata", -1)
 }
 
diff --git a/plugin/entryContent_test.go b/plugin/entryContent_test.go
new file mode 100644
index 0000000..833a374
--- /dev/null
+++ b/plugin/entryContent_test.go
@@ -0,0 +1,107 @@
+package plugin
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"testing"
+
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/suite"
+)
+
+type EntryContentTestSuite struct {
+	suite.Suite
+}
+
+func (s *EntryContentTestSuite) TestEntryContentImpl() {
+	// EntryContentImpl will likely never change, so go ahead and group
+	// all its tests here
+	rawContent := []byte("some raw content")
+	contentSize := int64(len(rawContent))
+	content := newEntryContent(rawContent)
+
+	// Test entryContentImpl#size
+	s.Equal(uint64(len(rawContent)), content.size())
+
+	// Now test entryContentImpl#read
+	type testCase struct {
+		size     int64
+		offset   int64
+		expected []byte
+		err      error
+	}
+	testCases := []testCase{
+		// Test offset >= contentSize
+		testCase{size: 0, offset: contentSize, expected: []byte(""), err: io.EOF},
+		testCase{size: 0, offset: contentSize + 1, expected: []byte(""), err: io.EOF},
+		// Test happy-cases
+		testCase{size: 0, offset: 0, expected: []byte("")},
+		testCase{size: 0, offset: 1, expected: []byte("")},
+		testCase{size: 4, offset: 2, expected: []byte("me r")},
+		testCase{size: contentSize, offset: 0, expected: rawContent},
+		// Test out-of-bounds sizes
+		testCase{size: contentSize + 1, offset: 0, expected: rawContent, err: io.EOF},
+		testCase{size: contentSize, offset: 1, expected: rawContent[1:], err: io.EOF},
+	}
+	for _, testCase := range testCases {
+		actual, err := content.read(context.Background(), testCase.size, testCase.offset)
+		if testCase.err != nil {
+			s.Equal(testCase.err, err)
+		} else {
+			s.NoError(err)
+		}
+		if testCase.expected != nil {
+			s.Equal(testCase.expected, actual)
+		}
+	}
+}
+
+func (s *EntryContentTestSuite) TestBlockReadableEntryContent_Size() {
+	content := newBlockReadableEntryContent(func(_ context.Context, _ int64, _ int64) ([]byte, error) {
+		return nil, nil
+	})
+	content.sz = 10
+	s.Equal(uint64(10), content.size())
+}
+
+type mockBlockReadableEntry struct {
+	mock.Mock
+}
+
+func (m *mockBlockReadableEntry) Read(ctx context.Context, size int64, offset int64) ([]byte, error) {
+	args := m.Called(ctx, size, offset)
+	return args.Get(0).([]byte), args.Error(1)
+}
+
+func (s *EntryContentTestSuite) TestBlockReadableEntryContent_Read_SuccessfulReadFuncInvocation() {
+	m := &mockBlockReadableEntry{}
+	content := newBlockReadableEntryContent(func(ctx context.Context, size int64, offset int64) ([]byte, error) {
+		return m.Read(ctx, size, offset)
+	})
+
+	ctx := context.Background()
+	m.On("Read", ctx, int64(10), int64(0)).Return([]byte("some raw content"), nil).Once()
+	rawContent, err := content.read(ctx, 10, 0)
+	if s.NoError(err) {
+		s.Equal([]byte("some raw content"), rawContent)
+	}
+}
+
+func (s *EntryContentTestSuite) TestBlockReadableEntryContent_Read_ErroredReadFuncInvocation() {
+	m := &mockBlockReadableEntry{}
+	content := newBlockReadableEntryContent(func(ctx context.Context, size int64, offset int64) ([]byte, error) {
+		return m.Read(ctx, size, offset)
+	})
+
+	ctx := context.Background()
+	expectedErr := fmt.Errorf("an error")
+	m.On("Read", ctx, int64(10), int64(0)).Return([]byte{}, expectedErr).Once()
+
+	_, err := content.read(ctx, 10, 0)
+	s.Equal(expectedErr, err)
+}
+
+func TestEntryContent(t *testing.T) {
+	suite.Run(t, new(EntryContentTestSuite))
+}
diff --git a/plugin/externalPluginEntry_test.go b/plugin/externalPluginEntry_test.go
index ef82665..035c503 100644
--- a/plugin/externalPluginEntry_test.go
+++ b/plugin/externalPluginEntry_test.go
@@ -80,7 +80,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryRequired
 		suite.Equal(decodedEntry.Name, entry.name())
 		suite.Equal(1, len(entry.methods))
 		suite.Contains(entry.methods, "list")
-		suite.Nil(entry.methods["list"])
+		suite.Nil(entry.methods["list"].result)
 	}
 }
 
@@ -94,9 +94,9 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryExtraFie
 	if suite.NoError(err) {
 		suite.Equal(decodedEntry.Name, entry.name())
 		suite.Contains(entry.methods, "list")
-		suite.Nil(entry.methods["list"])
+		suite.Nil(entry.methods["list"].result)
 		suite.Contains(entry.methods, "stream")
-		suite.Nil(entry.methods["stream"])
+		suite.Nil(entry.methods["stream"].result)
 		suite.False(entry.isPrefetched())
 
 		methods := entry.supportedMethods()
@@ -129,9 +129,9 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithMeth
 	if suite.NoError(err) {
 		suite.Equal(decodedEntry.Name, entry.name())
 		suite.Contains(entry.methods, "list")
-		suite.NotNil(entry.methods["list"])
+		suite.NotNil(entry.methods["list"].result)
 		suite.Contains(entry.methods, "read")
-		suite.Nil(entry.methods["read"])
+		suite.Nil(entry.methods["read"].result)
 		suite.True(entry.isPrefetched())
 	}
 }
@@ -290,7 +290,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 	entry.setCacheTTLs(decodedTTLs)
 
 	suite.Equal(decodedTTLs.List*time.Second, entry.getTTLOf(ListOp))
-	suite.Equal(decodedTTLs.Read*time.Second, entry.getTTLOf(OpenOp))
+	suite.Equal(decodedTTLs.Read*time.Second, entry.getTTLOf(ReadOp))
 	suite.Equal(decodedTTLs.Metadata*time.Second, entry.getTTLOf(MetadataOp))
 }
 
@@ -313,8 +313,8 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_PanicsIfNoSchem
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
 		rawTypeID: "fooTypeID",
-		methods: map[string]interface{}{
-			"schema": nil,
+		methods: map[string]methodInfo{
+			"schema": methodInfo{},
 		},
 		schemaGraphs: make(map[string]*linkedhashmap.Map),
 	}
@@ -331,8 +331,8 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsTheSchem
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
 		rawTypeID: "fooTypeID",
-		methods: map[string]interface{}{
-			"schema": nil,
+		methods: map[string]methodInfo{
+			"schema": methodInfo{},
 		},
 		schemaGraphs: make(map[string]*linkedhashmap.Map),
 		script:       mockScript,
@@ -361,9 +361,9 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsErrorIfS
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
 		rawTypeID: "fooTypeID",
-		methods: map[string]interface{}{
-			"schema": nil,
-			"read":   nil,
+		methods: map[string]methodInfo{
+			"schema": methodInfo{},
+			"read":   methodInfo{},
 		},
 		schemaGraphs: make(map[string]*linkedhashmap.Map),
 		script:       mockScript,
@@ -387,8 +387,8 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_ReturnsError
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
 		rawTypeID: "fooTypeID",
-		methods: map[string]interface{}{
-			"schema": nil,
+		methods: map[string]methodInfo{
+			"schema": methodInfo{},
 		},
 		script: mockScript,
 	}
@@ -405,8 +405,8 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_ReturnsError
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
 		rawTypeID: "fooTypeID",
-		methods: map[string]interface{}{
-			"schema": nil,
+		methods: map[string]methodInfo{
+			"schema": methodInfo{},
 		},
 		script: mockScript,
 	}
@@ -423,8 +423,8 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_SuccessfulIn
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
 		rawTypeID: "baz.fooTypeID",
-		methods: map[string]interface{}{
-			"schema": nil,
+		methods: map[string]methodInfo{
+			"schema": methodInfo{},
 		},
 		script: mockScript,
 	}
@@ -511,8 +511,12 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 		entryBase := NewEntry("foo")
 		expectedEntries := []Entry{
 			&externalPluginEntry{
-				EntryBase:    entryBase,
-				methods:      map[string]interface{}{"list": nil},
+				EntryBase: entryBase,
+				methods: map[string]methodInfo{
+					"list": methodInfo{
+						signature: defaultSignature,
+					},
+				},
 				script:       entry.script,
 				schemaGraphs: entry.schemaGraphs,
 				rawTypeID:    "bar",
@@ -523,7 +527,7 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 	}
 }
 
-func (suite *ExternalPluginEntryTestSuite) TestOpen() {
+func (suite *ExternalPluginEntryTestSuite) TestRead() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
@@ -539,20 +543,20 @@ func (suite *ExternalPluginEntryTestSuite) TestOpen() {
 	// Test that if InvokeAndWait errors, then Open returns its error
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait([]byte{}, mockErr)
-	_, err := entry.Open(ctx)
+	_, err := entry.Read(ctx)
 	suite.EqualError(mockErr, err.Error())
 
 	// Test that Open wraps all of stdout into a SizedReader
 	stdout := "foo"
 	mockInvokeAndWait([]byte(stdout), nil)
-	rdr, err := entry.Open(ctx)
+	content, err := entry.Read(ctx)
 	if suite.NoError(err) {
-		expectedRdr := bytes.NewReader([]byte(stdout))
-		suite.Equal(expectedRdr, rdr)
+		expectedContent := []byte(stdout)
+		suite.Equal(expectedContent, content)
 	}
 }
 
-func (suite *ExternalPluginEntryTestSuite) TestListOpenWithMethodResults() {
+func (suite *ExternalPluginEntryTestSuite) TestListReadWithMethodResults() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
@@ -589,12 +593,9 @@ func (suite *ExternalPluginEntryTestSuite) TestListOpenWithMethodResults() {
 				}
 
 				if suite.Equal([]string{"read"}, SupportedActionsOf(children[0])) {
-					rdr, err := children[0].(Readable).Open(ctx)
+					content, err := children[0].(Readable).Read(ctx)
 					suite.NoError(err)
-					buf := make([]byte, rdr.Size())
-					_, err = rdr.ReadAt(buf, 0)
-					suite.NoError(err)
-					suite.Equal(someContent, string(buf))
+					suite.Equal(someContent, string(content))
 				}
 			}
 		}
@@ -631,8 +632,9 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeWithErrors() {
 		suite.EqualError(err, `implementation of list must conform to `+
 			`[{"name":"entry1","methods":["list"]},{"name":"entry2","methods":["list"]}], not map[name:bar]`)
 
-		_, err = entries[0].(Readable).Open(ctx)
+		_, err = entries[0].(Readable).Read(ctx)
 		suite.EqualError(err, "Read method must provide a string, not [1 2]")
+		// TODO: Add a test for block readable here
 	}
 }
 
@@ -659,7 +661,7 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata_Implemented() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
-		methods:   map[string]interface{}{"metadata": nil},
+		methods:   map[string]methodInfo{"metadata": methodInfo{}},
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -695,7 +697,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSignal() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
-		methods:   map[string]interface{}{"signal": nil},
+		methods:   map[string]methodInfo{"signal": methodInfo{}},
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -723,7 +725,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDelete() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
-		methods:   map[string]interface{}{"delete": nil},
+		methods:   map[string]methodInfo{"delete": methodInfo{}},
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
diff --git a/plugin/externalPluginRoot_test.go b/plugin/externalPluginRoot_test.go
index b83ecc6..a6cbde7 100644
--- a/plugin/externalPluginRoot_test.go
+++ b/plugin/externalPluginRoot_test.go
@@ -52,7 +52,9 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 		expectedRoot := &externalPluginRoot{
 			externalPluginEntry: &externalPluginEntry{
 				EntryBase: NewEntry("foo"),
-				methods:   map[string]interface{}{"list": nil},
+				methods: map[string]methodInfo{
+					"list": methodInfo{signature: defaultSignature},
+				},
 				script:    root.script,
 				rawTypeID: "foo_type",
 			},
diff --git a/plugin/methodWrappers_test.go b/plugin/methodWrappers_test.go
index e7e1f92..754e38a 100644
--- a/plugin/methodWrappers_test.go
+++ b/plugin/methodWrappers_test.go
@@ -3,6 +3,7 @@ package plugin
 import (
 	"context"
 	"fmt"
+	"io"
 	"regexp"
 	"testing"
 	"time"
@@ -82,6 +83,11 @@ func (m *methodWrappersTestsMockEntry) Signal(ctx context.Context, signal string
 	return args.Error(0)
 }
 
+func (m *methodWrappersTestsMockEntry) Read(ctx context.Context) ([]byte, error) {
+	args := m.Called(ctx)
+	return args.Get(0).([]byte), args.Error(1)
+}
+
 func newMethodWrappersTestsMockEntry(name string) *methodWrappersTestsMockEntry {
 	e := &methodWrappersTestsMockEntry{
 		EntryBase: NewEntry(name),
@@ -108,6 +114,141 @@ func (suite *MethodWrappersTestSuite) TestPrefetched() {
 	suite.True(IsPrefetched(e))
 }
 
+func (suite *MethodWrappersTestSuite) TestRead_PanicsOnNonReadableEntry() {
+	// Use an external plugin entry because they're easier to setup
+	panicFunc := func() {
+		entry := &externalPluginEntry{
+			EntryBase: NewEntry("foo"),
+		}
+		_, _ = Read(context.Background(), entry, 10, 0)
+	}
+	suite.Panics(panicFunc, "plugin.Read called on a non-readable entry")
+}
+
+func (suite *MethodWrappersTestSuite) TestRead_InvalidSizeAndOffset() {
+	e := newMethodWrappersTestsMockEntry("mockEntry")
+
+	_, err := Read(context.Background(), e, -1, 0)
+	suite.Regexp("negative.*size.*-1", err)
+
+	_, err = Read(context.Background(), e, 0, -1)
+	suite.Regexp("negative.*offset.*-1", err)
+}
+
+func (suite *MethodWrappersTestSuite) TestRead_ReturnsCachedReadError() {
+	// This test-case only applies to Readable entries
+	e := newMethodWrappersTestsMockEntry("mockEntry")
+	e.DisableDefaultCaching()
+	e.SetTestID("/foo")
+
+	ctx := context.Background()
+	expectedErr := fmt.Errorf("an error")
+	e.On("Read", ctx).Return([]byte{}, expectedErr)
+
+	_, err := Read(ctx, e, 2, 1)
+	suite.Equal(expectedErr, err)
+}
+
+type methodWrappersTestsMockBlockReadableEntry struct {
+	*methodWrappersTestsMockEntry
+}
+
+func (m *methodWrappersTestsMockBlockReadableEntry) Read(ctx context.Context, size int64, offset int64) ([]byte, error) {
+	args := m.Called(ctx, size, offset)
+	return args.Get(0).([]byte), args.Error(1)
+}
+
+func (suite *MethodWrappersTestSuite) TestRead_ReturnsContentReadError() {
+	// This test-case only applies to BlockReadable entries
+	e := &methodWrappersTestsMockBlockReadableEntry{
+		newMethodWrappersTestsMockEntry("mockEntry"),
+	}
+	e.DisableDefaultCaching()
+	e.SetTestID("/foo")
+
+	ctx := context.Background()
+	expectedErr := fmt.Errorf("an error")
+	e.On("Read", ctx, int64(10), int64(0)).Return([]byte{}, expectedErr)
+
+	_, err := Read(ctx, e, 10, 0)
+	suite.Equal(expectedErr, err)
+}
+
+func (suite *MethodWrappersTestSuite) TestRead_PluginAPIReturnsMoreThanTheRequestedData() {
+	// This test-case only applies to BlockReadable entries
+	e := &methodWrappersTestsMockBlockReadableEntry{
+		newMethodWrappersTestsMockEntry("mockEntry"),
+	}
+	e.DisableDefaultCaching()
+	e.SetTestID("/foo")
+
+	ctx := context.Background()
+	e.On("Read", ctx, int64(1), int64(0)).Return([]byte("content"), nil)
+
+	_, err := Read(ctx, e, 1, 0)
+	suite.Regexp("requested.*1.*input.*1.*plugin.*7.*bytes", err)
+}
+
+func (suite *MethodWrappersTestSuite) TestRead_ReadsTheEntryContent() {
+	e := newMethodWrappersTestsMockEntry("mockEntry")
+	e.DisableDefaultCaching()
+	e.SetTestID("/foo")
+
+	ctx := context.Background()
+	e.On("Read", ctx).Return([]byte("some raw content"), nil).Once()
+
+	rawContent, err := Read(ctx, e, 2, 1)
+	if suite.NoError(err) {
+		suite.Equal([]byte("om"), rawContent)
+	}
+}
+
+func (suite *MethodWrappersTestSuite) TestRead_EntryHasSizeAttribute() {
+	rawContent := []byte("some raw content")
+	contentSize := int64(len(rawContent))
+
+	e := &methodWrappersTestsMockBlockReadableEntry{
+		newMethodWrappersTestsMockEntry("mockEntry"),
+	}
+	e.DisableDefaultCaching()
+	e.SetTestID("/foo")
+	e.Attributes().SetSize(uint64(contentSize))
+
+	ctx := context.Background()
+
+	// Test that out-of-bounds offset does the right thing.
+	data, err := Read(ctx, e, 0, contentSize)
+	suite.Equal(io.EOF, err)
+	suite.Equal([]byte{}, data)
+	data, err = Read(ctx, e, 0, contentSize+1)
+	suite.Equal(io.EOF, err)
+	suite.Equal([]byte{}, data)
+
+	// Now test that the right "size" parameter is passed in to
+	// entryContent#read
+	type testCase struct {
+		size         int64
+		offset       int64
+		expectedSize int64
+	}
+	testCases := []testCase{
+		// Test with an in-bounds size
+		testCase{contentSize - 1, 0, contentSize - 1},
+		// Test with an out-of-bounds size
+		testCase{contentSize + 1, 0, contentSize},
+	}
+	for _, testCase := range testCases {
+		e.On("Read", ctx, testCase.expectedSize, testCase.offset).Return([]byte("success"), nil).Once()
+		actual, err := Read(context.Background(), e, testCase.size, testCase.offset)
+		if testCase.expectedSize != testCase.size {
+			suite.Equal(io.EOF, err)
+		} else {
+			suite.NoError(err)
+		}
+		suite.Equal([]byte("success"), actual)
+	}
+}
+
 func (suite *MethodWrappersTestSuite) TestSignal_ReturnsSignalError() {
 	ctx := context.Background()
 	e := newMethodWrappersTestsMockEntry("foo")
diff --git a/volume/dir_test.go b/volume/dir_test.go
index 150867a..0962f9c 100644
--- a/volume/dir_test.go
+++ b/volume/dir_test.go
@@ -23,7 +23,7 @@ func (m *mockDirEntry) VolumeList(ctx context.Context, path string) (DirMap, err
 	return arger.Get(0).(DirMap), arger.Error(1)
 }
 
-func (m *mockDirEntry) VolumeOpen(context.Context, string) (plugin.SizedReader, error) {
+func (m *mockDirEntry) VolumeRead(context.Context, string) ([]byte, error) {
 	return nil, nil
 }
 
diff --git a/volume/file_test.go b/volume/file_test.go
index dea28ef..cb1a56c 100644
--- a/volume/file_test.go
+++ b/volume/file_test.go
@@ -23,11 +23,11 @@ func (m *mockFileEntry) VolumeList(context.Context, string) (DirMap, error) {
 	return nil, nil
 }
 
-func (m *mockFileEntry) VolumeOpen(context.Context, string) (plugin.SizedReader, error) {
+func (m *mockFileEntry) VolumeRead(context.Context, string) ([]byte, error) {
 	if m.err != nil {
 		return nil, m.err
 	}
-	return strings.NewReader(m.content), nil
+	return []byte(m.content), nil
 }
 
 func (m *mockFileEntry) VolumeStream(context.Context, string) (io.ReadCloser, error) {
@@ -58,20 +58,14 @@ func TestVolumeFile(t *testing.T) {
 	expectedAttr.SetCtime(now)
 	assert.Equal(t, expectedAttr, attr)
 
-	rdr, err := vf.Open(context.Background())
+	content, err := vf.Read(context.Background())
 	assert.Nil(t, err)
-	if assert.NotNil(t, rdr) {
-		buf := make([]byte, rdr.Size())
-		n, err := rdr.ReadAt(buf, 0)
-		assert.Nil(t, err)
-		assert.Equal(t, int64(n), rdr.Size())
-		assert.Equal(t, "hello", string(buf))
-	}
+	assert.Equal(t, []byte("hello"), content)
 
-	rdr2, err := plugin.Stream(context.Background(), vf)
+	rdr, err := plugin.Stream(context.Background(), vf)
 	assert.Nil(t, err)
-	if assert.NotNil(t, rdr2) {
-		buf, err := ioutil.ReadAll(rdr2)
+	if assert.NotNil(t, rdr) {
+		buf, err := ioutil.ReadAll(rdr)
 		if assert.NoError(t, err) {
 			assert.Equal(t, "hello", string(buf))
 		}
@@ -82,11 +76,11 @@ func TestVolumeFileErr(t *testing.T) {
 	impl := &mockFileEntry{EntryBase: plugin.NewEntry("parent"), err: errors.New("fail")}
 	vf := newFile("mine", plugin.EntryAttributes{}, impl, "my path")
 
-	rdr, err := vf.Open(context.Background())
-	assert.Nil(t, rdr)
+	content, err := vf.Read(context.Background())
+	assert.Nil(t, content)
 	assert.Equal(t, errors.New("fail"), err)
 
-	rdr2, err := plugin.Stream(context.Background(), vf)
-	assert.Nil(t, rdr2)
+	rdr, err := plugin.Stream(context.Background(), vf)
+	assert.Nil(t, rdr)
 	assert.Equal(t, errors.New("fail"), err)
 }
diff --git a/volume/fs_test.go b/volume/fs_test.go
index 36a8267..a7df6bf 100644
--- a/volume/fs_test.go
+++ b/volume/fs_test.go
@@ -217,14 +217,10 @@ func (suite *fsTestSuite) TestFSRead() {
 
 	execResult := suite.createResult("hello")
 	exec.On("Exec", mock.Anything, "cat", []string{"/var/log/path1/a file"}, plugin.ExecOptions{Elevate: true}).Return(execResult, nil)
-	rdr, err := entry.(plugin.Readable).Open(context.Background())
-	suite.NoError(err)
-	suite.Equal(int64(5), rdr.Size())
-	buf := make([]byte, 5)
-	n, err := rdr.ReadAt(buf, 0)
-	suite.NoError(err)
-	suite.Equal(5, n)
-	suite.Equal("hello", string(buf))
+	content, err := entry.(plugin.Readable).Read(context.Background())
+	if suite.NoError(err) {
+		suite.Equal([]byte("hello"), content)
+	}
 }
 
 func (suite *fsTestSuite) TestVolumeDelete() {