Repository: puppetlabs/wash
Pull request #176 (2019-03-29 07:56:50)

Generalize the concept of attributes and metadata


Patch:
diff --git a/api/helpers.go b/api/helpers.go
index 5fad3d0..8afb7a4 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -5,14 +5,42 @@ import (
 	"fmt"
 	"strings"
 
+	apitypes "github.com/puppetlabs/wash/api/types"
 	"github.com/puppetlabs/wash/plugin"
 )
 
+func toAPIAttr(attr plugin.EntryAttributes) apitypes.EntryAttributes {
+	apiAttr := apitypes.EntryAttributes{}
+
+	if attr.HasAtime() {
+		atime := attr.Atime()
+		apiAttr.Atime = &atime
+	}
+	if attr.HasMtime() {
+		mtime := attr.Mtime()
+		apiAttr.Mtime = &mtime
+	}
+	if attr.HasCtime() {
+		ctime := attr.Ctime()
+		apiAttr.Ctime = &ctime
+	}
+	if attr.HasMode() {
+		mode := attr.Mode()
+		apiAttr.Mode = &mode
+	}
+	if attr.HasSize() {
+		size := attr.Size()
+		apiAttr.Size = &size
+	}
+
+	return apiAttr
+}
+
 func findEntry(ctx context.Context, root plugin.Entry, segments []string) (plugin.Entry, *errorResponse) {
 	path := strings.Join(segments, "/")
 
 	curEntry := root
-	curEntryID := "/" + root.Name()
+	curEntryID := "/" + plugin.CName(root)
 
 	visitedSegments := make([]string, 0, cap(segments))
 	for _, segment := range segments {
diff --git a/api/list.go b/api/list.go
index c2b52f1..b02a652 100644
--- a/api/list.go
+++ b/api/list.go
@@ -54,19 +54,13 @@ var listHandler handler = func(w http.ResponseWriter, r *http.Request, p params)
 	info := func(entry plugin.Entry) apitypes.ListEntry {
 		result := apitypes.ListEntry{
 			Path:    plugin.Path(entry),
-			Name:    entry.Name(),
+			Name:    plugin.Name(entry),
 			CName:   plugin.CName(entry),
 			Actions: plugin.SupportedActionsOf(entry),
 			Errors:  make(map[string]*apitypes.ErrorObj),
 		}
 
-		attr, err := plugin.Attr(r.Context(), entry)
-		if err != nil {
-			result.Errors["attributes"] = newUnknownErrorObj(err)
-		} else {
-			result.Attributes = attr
-		}
-
+		result.Attributes = toAPIAttr(plugin.Attributes(entry))
 		return result
 	}
 
diff --git a/api/metadata.go b/api/metadata.go
index a7c5068..7e17bd7 100644
--- a/api/metadata.go
+++ b/api/metadata.go
@@ -11,8 +11,8 @@ import (
 
 // swagger:response
 //nolint:deadcode,unused
-type metadataMap struct {
-	MetadataMap plugin.MetadataMap
+type entryMetadata struct {
+	EntryMetadata plugin.EntryMetadata
 }
 
 // swagger:route GET /fs/metadata metadata getMetadata
@@ -38,16 +38,12 @@ var metadataHandler handler = func(w http.ResponseWriter, r *http.Request, p par
 		return errResp
 	}
 
-	if !plugin.MetadataAction.IsSupportedOn(entry) {
-		return unsupportedActionResponse(path, plugin.MetadataAction)
-	}
-
 	journal.Record(ctx, "API: Metadata %v", path)
-	metadata, err := plugin.CachedMetadata(ctx, entry.(plugin.Resource))
+	metadata, err := plugin.CachedMetadata(ctx, entry)
 
 	if err != nil {
 		journal.Record(ctx, "API: Metadata %v errored: %v", path, err)
-		return erroredActionResponse(path, plugin.MetadataAction, err.Error())
+		return unknownErrorResponse(err)
 	}
 	journal.Record(ctx, "API: Metadata %v %+v", path, metadata)
 
diff --git a/api/types/list.go b/api/types/list.go
index 930343e..df410a1 100644
--- a/api/types/list.go
+++ b/api/types/list.go
@@ -1,9 +1,21 @@
 package apitypes
 
 import (
-	"github.com/puppetlabs/wash/plugin"
+	"os"
+	"time"
 )
 
+// EntryAttributes represents an entry's attributes.
+//
+// TODO: Add meta later
+type EntryAttributes struct {
+	Atime *time.Time   `json:"atime,omitempty"`
+	Mtime *time.Time   `json:"mtime,omitempty"`
+	Ctime *time.Time   `json:"ctime,omitempty"`
+	Mode  *os.FileMode `json:"mode,omitempty"`
+	Size  *uint64      `json:"size,omitempty"`
+}
+
 // ListEntry represents a single entry from the result of issuing a wash 'list' request.
 //
 // swagger:response
@@ -12,6 +24,6 @@ type ListEntry struct {
 	Actions    []string             `json:"actions"`
 	Name       string               `json:"name"`
 	CName      string               `json:"cname"`
-	Attributes plugin.Attributes    `json:"attributes"`
+	Attributes EntryAttributes      `json:"attributes"`
 	Errors     map[string]*ErrorObj `json:"errors"`
 }
diff --git a/cmd/ls.go b/cmd/ls.go
index 46dc435..fed426a 100644
--- a/cmd/ls.go
+++ b/cmd/ls.go
@@ -36,10 +36,7 @@ func formatListEntries(apiPath string, ls []apitypes.ListEntry) string {
 	table := make([][]string, len(ls))
 	for i, entry := range ls {
 		var ctimeStr string
-		if err, ok := entry.Errors["attributes"]; ok {
-			cmdutil.ErrPrintf("errored on %v: could not retrieve the attributes: %v\n", entry.Name, err)
-			ctimeStr = "<unknown>"
-		} else if entry.Attributes.Ctime.IsZero() {
+		if entry.Attributes.Ctime == nil {
 			ctimeStr = "<unknown>"
 		} else {
 			ctimeStr = entry.Attributes.Ctime.Format(time.RFC822)
diff --git a/fuse/core.go b/fuse/core.go
index fc516b7..abd9e2c 100644
--- a/fuse/core.go
+++ b/fuse/core.go
@@ -3,6 +3,7 @@ package fuse
 
 import (
 	"context"
+	"os"
 	"os/user"
 	"strconv"
 	"time"
@@ -51,41 +52,60 @@ func getIDs() (uint32, uint32) {
 
 var uid, gid = getIDs()
 
-type fuseNode interface {
-	Entry() plugin.Entry
-	String() string
+var attrRefreshInterval = 5 * time.Second
+
+type fuseNode struct {
+	ftype             string
+	entry             plugin.Entry
+	entryCreationTime time.Time
 }
 
-func ftype(f fuseNode) string {
-	if _, ok := f.(*dir); ok {
-		return "d"
+func newFuseNode(ftype string, entry plugin.Entry) *fuseNode {
+	return &fuseNode{
+		ftype:             ftype,
+		entry:             entry,
+		entryCreationTime: time.Now(),
 	}
-	return "f"
 }
 
+func (f *fuseNode) String() string {
+	return plugin.Path(f.entry)
+}
+
+const sizeUnknown = ^uint64(0)
+
 // Applies attributes where non-default, and sets defaults otherwise.
-func applyAttr(a *fuse.Attr, attr *plugin.Attributes) {
-	a.Valid = 1 * time.Second
-	if attr.Valid != 0 {
-		a.Valid = attr.Valid
-	}
+func (f *fuseNode) applyAttr(a *fuse.Attr, attr *plugin.EntryAttributes) {
+	// This doesn't quite work for some reason.
+	a.Valid = attrRefreshInterval
 
 	// TODO: tie this to actual hard links in plugins
 	a.Nlink = 1
-	a.Mode = attr.Mode
-	a.Size = attr.Size
+
+	if attr.HasMode() {
+		a.Mode = attr.Mode()
+	} else if plugin.ListAction.IsSupportedOn(f.entry) {
+		a.Mode = os.ModeDir | 0550
+	} else {
+		a.Mode = 0440
+	}
+
+	a.Size = sizeUnknown
+	if attr.HasSize() {
+		a.Size = attr.Size()
+	}
 
 	a.Mtime = startTime
-	if !attr.Mtime.IsZero() {
-		a.Mtime = attr.Mtime
+	if attr.HasMtime() {
+		a.Mtime = attr.Mtime()
 	}
 	a.Atime = startTime
-	if !attr.Atime.IsZero() {
-		a.Atime = attr.Atime
+	if attr.HasAtime() {
+		a.Atime = attr.Atime()
 	}
 	a.Ctime = startTime
-	if !attr.Ctime.IsZero() {
-		a.Ctime = attr.Ctime
+	if attr.HasCtime() {
+		a.Ctime = attr.Ctime()
 	}
 	a.Crtime = startTime
 	a.BlockSize = 4096
@@ -93,28 +113,31 @@ func applyAttr(a *fuse.Attr, attr *plugin.Attributes) {
 	a.Gid = gid
 }
 
-func attr(ctx context.Context, f fuseNode, a *fuse.Attr) error {
-	attr, err := plugin.Attr(ctx, f.Entry())
-	if _, ok := err.(plugin.ErrCouldNotDetermineSizeAttr); ok {
-		log.Warnf("FUSE: Warn[Attr,%v]: %v", f, err)
-	} else if err != nil {
-		log.Warnf("FUSE: Error[Attr,%v]: %v", f, err)
-		return err
+func (f *fuseNode) Attr(ctx context.Context, a *fuse.Attr) error {
+	// TODO: The code below's a temporary hack to show that the refreshing
+	// behavior works. It blows up after several successive ls calls b/c each
+	// child simultaneously attempts to refresh their attributes.
+	if time.Since(f.entryCreationTime) >= attrRefreshInterval {
+		err := plugin.RefreshAttributes(ctx, f.entry)
+		if err != nil {
+			log.Warnf("FUSE: Error[Attr,%v]: %v", f, err)
+			return err
+		}
 	}
-
-	applyAttr(a, &attr)
-	log.Infof("FUSE: Attr[%v] %v %v", ftype(f), f, a)
+	attr := plugin.Attributes(f.entry)
+	f.applyAttr(a, &attr)
+	log.Infof("FUSE: Attr[%v] %v %v", f.ftype, f, a)
 	return nil
 }
 
-func listxattr(ctx context.Context, f fuseNode, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error {
-	log.Infof("FUSE: Listxattr[%v,pid=%v] %v", ftype(f), req.Pid, f)
+func (f *fuseNode) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error {
+	log.Infof("FUSE: Listxattr[%v,pid=%v] %v", f.ftype, req.Pid, f)
 	resp.Append("wash.id")
 	return nil
 }
 
-func getxattr(ctx context.Context, f fuseNode, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error {
-	log.Infof("FUSE: Getxattr[%v,pid=%v] %v", ftype(f), req.Pid, f)
+func (f *fuseNode) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error {
+	log.Infof("FUSE: Getxattr[%v,pid=%v] %v", f.ftype, req.Pid, f)
 	switch req.Name {
 	case "wash.id":
 		resp.Xattr = []byte(f.String())
diff --git a/fuse/dir.go b/fuse/dir.go
index 077885d..fe6b3f1 100644
--- a/fuse/dir.go
+++ b/fuse/dir.go
@@ -13,7 +13,7 @@ import (
 // ==== FUSE Directory Interface ====
 
 type dir struct {
-	entry plugin.Entry
+	*fuseNode
 }
 
 var _ fs.Node = (*dir)(nil)
@@ -21,37 +21,13 @@ var _ = fs.NodeRequestLookuper(&dir{})
 var _ = fs.HandleReadDirAller(&dir{})
 
 func newDir(e plugin.Group) *dir {
-	return &dir{e}
-}
-
-func (d *dir) Entry() plugin.Entry {
-	return d.entry
-}
-
-func (d *dir) String() string {
-	return plugin.Path(d.entry)
-}
-
-// Attr returns the attributes of a directory.
-func (d *dir) Attr(ctx context.Context, a *fuse.Attr) error {
-	// TODO: need an enhancement to bazil.org/fuse to pass request to a method like Attr.
-	return attr(context.WithValue(ctx, journal.Key, ""), d, a)
-}
-
-// Listxattr lists extended attributes for the resource.
-func (d *dir) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error {
-	return listxattr(ctx, d, req, resp)
-}
-
-// Getxattr gets extended attributes for the resource.
-func (d *dir) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error {
-	return getxattr(ctx, d, req, resp)
+	return &dir{newFuseNode("d", e)}
 }
 
 func (d *dir) children(ctx context.Context) ([]plugin.Entry, error) {
 	// Cache List requests. FUSE often lists the contents then immediately calls find on individual entries.
-	if plugin.ListAction.IsSupportedOn(d.Entry()) {
-		return plugin.CachedList(ctx, d.Entry().(plugin.Group))
+	if plugin.ListAction.IsSupportedOn(d.entry) {
+		return plugin.CachedList(ctx, d.entry.(plugin.Group))
 	}
 
 	return []plugin.Entry{}, fuse.ENOENT
@@ -112,7 +88,7 @@ func (d *dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
 	for i, entry := range entries {
 		var de fuse.Dirent
 		de.Name = plugin.CName(entry)
-		if plugin.ListAction.IsSupportedOn(d.Entry()) {
+		if plugin.ListAction.IsSupportedOn(d.entry) {
 			de.Type = fuse.DT_Dir
 		}
 		res[i] = de
diff --git a/fuse/file.go b/fuse/file.go
index 81f92e6..61a9f07 100644
--- a/fuse/file.go
+++ b/fuse/file.go
@@ -14,7 +14,7 @@ import (
 // ==== FUSE file Interface ====
 
 type file struct {
-	entry plugin.Entry
+	*fuseNode
 }
 
 var _ fs.Node = (*file)(nil)
@@ -23,31 +23,7 @@ var _ = fs.NodeGetxattrer(&file{})
 var _ = fs.NodeListxattrer(&file{})
 
 func newFile(e plugin.Entry) *file {
-	return &file{e}
-}
-
-func (f *file) Entry() plugin.Entry {
-	return f.entry
-}
-
-func (f *file) String() string {
-	return plugin.Path(f.entry)
-}
-
-// Attr returns the attributes of a file.
-func (f *file) Attr(ctx context.Context, a *fuse.Attr) error {
-	// TODO: need an enhancement to bazil.org/fuse to pass request to a method like Attr.
-	return attr(context.WithValue(ctx, journal.Key, ""), f, a)
-}
-
-// Listxattr lists extended attributes for the resource.
-func (f *file) Listxattr(ctx context.Context, req *fuse.ListxattrRequest, resp *fuse.ListxattrResponse) error {
-	return listxattr(ctx, f, req, resp)
-}
-
-// Getxattr gets extended attributes for the resource.
-func (f *file) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fuse.GetxattrResponse) error {
-	return getxattr(ctx, f, req, resp)
+	return &file{newFuseNode("f", e)}
 }
 
 // Open a file for reading.
@@ -57,8 +33,8 @@ func (f *file) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenR
 
 	// Initiate content request and return a channel providing the results.
 	log.Infof("FUSE: Opening[pid=%v] %v", req.Pid, f)
-	if plugin.ReadAction.IsSupportedOn(f.Entry()) {
-		content, err := plugin.CachedOpen(ctx, f.Entry().(plugin.Readable))
+	if plugin.ReadAction.IsSupportedOn(f.entry) {
+		content, err := plugin.CachedOpen(ctx, f.entry.(plugin.Readable))
 		if err != nil {
 			log.Warnf("FUSE: Error[Open,%v]: %v", f, err)
 			journal.Record(ctx, "FUSE: Open %v errored: %v", f, err)
diff --git a/plugin/action.go b/plugin/action.go
index c6b6d6e..3e6f122 100644
--- a/plugin/action.go
+++ b/plugin/action.go
@@ -25,9 +25,6 @@ func (a Action) IsSupportedOn(entry Entry) bool {
 	return false
 }
 
-// MetadataAction represents the metadata action
-var MetadataAction = newAction("metadata", "Resource")
-
 // ListAction represents the list action
 var ListAction = newAction("list", "Group")
 
@@ -54,9 +51,6 @@ func SupportedActionsOf(entry Entry) []string {
 		// 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.(Resource); ok {
-			actions = append(actions, MetadataAction.Name)
-		}
 		if _, ok := entry.(Group); ok {
 			actions = append(actions, ListAction.Name)
 		}
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index 38e0044..1a80657 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -2,7 +2,6 @@ package aws
 
 import (
 	"context"
-	"encoding/base64"
 	"fmt"
 	"io/ioutil"
 	"sync"
@@ -17,8 +16,6 @@ import (
 	"github.com/kballard/go-shellquote"
 	"github.com/puppetlabs/wash/journal"
 	"github.com/puppetlabs/wash/plugin"
-
-	log "github.com/sirupsen/logrus"
 )
 
 // ec2Instance represents an EC2 instance
@@ -29,7 +26,7 @@ type ec2Instance struct {
 	ssmClient               *ssm.SSM
 	cloudwatchClient        *cloudwatchlogs.CloudWatchLogs
 	latestConsoleOutputOnce sync.Once
-	entries                 []plugin.Entry
+	hasLatestConsoleOutput  bool
 }
 
 // These constants represent the possible states that the EC2 instance
@@ -44,9 +41,9 @@ const (
 	EC2InstanceStopped           = 80
 )
 
-func newEC2Instance(ctx context.Context, ID string, session *session.Session, client *ec2Client.EC2) *ec2Instance {
+func newEC2Instance(ctx context.Context, inst *ec2Client.Instance, session *session.Session, client *ec2Client.EC2) *ec2Instance {
 	ec2Instance := &ec2Instance{
-		EntryBase:        plugin.NewEntry(ID),
+		EntryBase:        plugin.NewEntry(awsSDK.StringValue(inst.InstanceId)),
 		session:          session,
 		client:           client,
 		ssmClient:        ssm.New(session),
@@ -54,17 +51,62 @@ func newEC2Instance(ctx context.Context, ID string, session *session.Session, cl
 	}
 	ec2Instance.DisableDefaultCaching()
 
-	ec2Instance.entries = []plugin.Entry{
-		newEC2InstanceMetadataJSON(ec2Instance),
-		newEC2InstanceConsoleOutput(ec2Instance, false),
-	}
+	metaObj := newDescribeInstanceResult(inst)
+
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(metaObj.ctime).
+		SetMtime(metaObj.mtime).
+		SetMeta(metaObj.toMeta())
+	ec2Instance.SetInitialAttributes(attr)
+
+	ec2Instance.
+		Sync(plugin.CtimeAttr(), "CreationTime").
+		Sync(plugin.MtimeAttr(), "LastModifiedTime")
 
 	return ec2Instance
 }
 
 type describeInstanceResult struct {
-	attr     plugin.Attributes
-	metadata *ec2Client.Instance
+	inst  *ec2Client.Instance
+	ctime time.Time
+	mtime time.Time
+}
+
+func newDescribeInstanceResult(inst *ec2Client.Instance) describeInstanceResult {
+	result := describeInstanceResult{
+		inst: inst,
+	}
+
+	// AWS does not include the EC2 instance's ctime in its
+	// metadata. It also does not include the EC2 instance's
+	// last state transition time (mtime). Thus, we try to "guess"
+	// reasonable values for ctime and mtime by looping over each
+	// block device's attachment time and the instance's launch time.
+	// The oldest of these times is the ctime; the newest is the mtime.
+	result.ctime = awsSDK.TimeValue(inst.LaunchTime)
+	result.mtime = result.ctime
+	for _, mapping := range inst.BlockDeviceMappings {
+		attachTime := awsSDK.TimeValue(mapping.Ebs.AttachTime)
+
+		if attachTime.Before(result.ctime) {
+			result.ctime = attachTime
+		}
+
+		if attachTime.After(result.mtime) {
+			result.mtime = attachTime
+		}
+	}
+
+	return result
+}
+
+func (d describeInstanceResult) toMeta() plugin.EntryMetadata {
+	meta := plugin.ToMeta(d.inst)
+	meta["CreationTime"] = d.ctime
+	meta["LastModifiedTime"] = d.mtime
+
+	return meta
 }
 
 func (inst *ec2Instance) cachedDescribeInstance(ctx context.Context) (describeInstanceResult, error) {
@@ -80,34 +122,8 @@ func (inst *ec2Instance) cachedDescribeInstance(ctx context.Context) (describeIn
 			return nil, err
 		}
 
-		result := describeInstanceResult{}
-
-		// The API returns an error for an invalid instance ID. Since
-		// our API call succeeded, the response is guaranteed to contain
-		// the instance's metadata
-		result.metadata = resp.Reservations[0].Instances[0]
-
-		// AWS does not include the EC2 instance's ctime in its
-		// metadata. It also does not include the EC2 instance's
-		// last state transition time (mtime). Thus, we try to "guess"
-		// reasonable values for ctime and mtime by looping over each
-		// block device's attachment time and the instance's launch time.
-		// The oldest of these times is the ctime; the newest is the mtime.
-		result.attr.Ctime = awsSDK.TimeValue(result.metadata.LaunchTime)
-		result.attr.Mtime = result.attr.Ctime
-		for _, mapping := range result.metadata.BlockDeviceMappings {
-			attachTime := awsSDK.TimeValue(mapping.Ebs.AttachTime)
-
-			if attachTime.Before(result.attr.Ctime) {
-				result.attr.Ctime = attachTime
-			}
-
-			if attachTime.After(result.attr.Mtime) {
-				result.attr.Mtime = attachTime
-			}
-		}
-
-		return result, nil
+		inst := resp.Reservations[0].Instances[0]
+		return newDescribeInstanceResult(inst), nil
 	})
 
 	if err != nil {
@@ -117,74 +133,47 @@ func (inst *ec2Instance) cachedDescribeInstance(ctx context.Context) (describeIn
 	return info.(describeInstanceResult), nil
 }
 
-func (inst *ec2Instance) Attr(ctx context.Context) (plugin.Attributes, error) {
-	result, err := inst.cachedDescribeInstance(ctx)
-	if err != nil {
-		return plugin.Attributes{}, err
-	}
-
-	return result.attr, nil
-}
-
-func (inst *ec2Instance) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
+func (inst *ec2Instance) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
 	result, err := inst.cachedDescribeInstance(ctx)
 	if err != nil {
 		return nil, err
 	}
 
-	return plugin.ToMetadata(result.metadata), nil
+	return result.toMeta(), nil
 }
 
 func (inst *ec2Instance) List(ctx context.Context) ([]plugin.Entry, error) {
+	var latestConsoleOutput *ec2InstanceConsoleOutput
+	var err error
 	inst.latestConsoleOutputOnce.Do(func() {
-		if inst.hasLatestConsoleOutput(ctx) {
-			inst.entries = append(inst.entries, newEC2InstanceConsoleOutput(inst, true))
-		}
+		latestConsoleOutput, err = inst.checkLatestConsoleOutput(ctx)
 	})
 
-	return inst.entries, nil
-}
-
-type consoleOutput struct {
-	mtime   time.Time
-	content []byte
-}
+	entries := []plugin.Entry{}
 
-func (inst *ec2Instance) cachedConsoleOutput(ctx context.Context, latest bool) (consoleOutput, error) {
-	opName := "ConsoleOutput"
-	if latest {
-		opName = "Latest" + opName
+	metadataJSON, err := newEC2InstanceMetadataJSON(ctx, inst)
+	if err != nil {
+		return nil, err
 	}
+	entries = append(entries, metadataJSON)
 
-	output, err := plugin.CachedOp(opName, inst, 30*time.Second, func() (interface{}, error) {
-		request := &ec2Client.GetConsoleOutputInput{
-			InstanceId: awsSDK.String(inst.Name()),
-		}
-		if latest {
-			request.Latest = awsSDK.Bool(latest)
-		}
-
-		resp, err := inst.client.GetConsoleOutputWithContext(ctx, request)
-		if err != nil {
-			return nil, err
-		}
+	consoleOutput, err := newEC2InstanceConsoleOutput(ctx, inst, false)
+	if err != nil {
+		return nil, err
+	}
+	entries = append(entries, consoleOutput)
 
-		content, err := base64.StdEncoding.DecodeString(awsSDK.StringValue(resp.Output))
-		if err != nil {
-			return nil, err
+	if inst.hasLatestConsoleOutput {
+		if latestConsoleOutput == nil {
+			latestConsoleOutput, err = newEC2InstanceConsoleOutput(ctx, inst, true)
+			if err != nil {
+				return nil, err
+			}
 		}
-
-		return consoleOutput{
-			mtime:   awsSDK.TimeValue(resp.Timestamp),
-			content: content,
-		}, nil
-	})
-
-	if err != nil {
-		return consoleOutput{}, err
+		entries = append(entries, latestConsoleOutput)
 	}
 
-	return output.(consoleOutput), nil
+	return entries, nil
 }
 
 // According to https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-console.html,
@@ -194,40 +183,43 @@ func (inst *ec2Instance) cachedConsoleOutput(ctx context.Context, latest bool) (
 // console output. Thus, this checks to see if our EC2 instance supports retrieving
 // the console logs, which reduces to checking whether we can open a
 // consoleLatestOutput object.
-func (inst *ec2Instance) hasLatestConsoleOutput(ctx context.Context) bool {
-	consoleLatestOutput := newEC2InstanceConsoleOutput(inst, true)
-
-	_, err := consoleLatestOutput.Open(ctx)
+//
+// NOTE: We return the object to avoid an extra request in List. The returned error
+// is whether something went wrong with opening the consoleLatestOutput object (so
+// that List can appropriately error).
+func (inst *ec2Instance) checkLatestConsoleOutput(ctx context.Context) (*ec2InstanceConsoleOutput, error) {
+	consoleLatestOutput, err := newEC2InstanceConsoleOutput(ctx, inst, true)
 	if err == nil {
-		return true
+		inst.hasLatestConsoleOutput = true
+		return consoleLatestOutput, nil
 	}
 
 	awserr, ok := err.(awserr.Error)
 	if !ok {
 		// Open failed w/ some other error, which should be a
-		// rare occurrence. Here we log a warning, reset
-		// latestConsoleOutputOnce so that we check again for
-		// the latest console output the next time List's called,
-		// then return false.
-		log.Warnf(
+		// 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
+		inst.latestConsoleOutputOnce = sync.Once{}
+		return nil, fmt.Errorf(
 			"could not determine whether the EC2 instance %v supports retrieving the latest console output: %v",
 			inst.Name(),
 			ctx.Err(),
 		)
-		inst.latestConsoleOutputOnce = sync.Once{}
-		return false
 	}
 
 	// For some reason, the EC2 client does not have this error code
 	// as a constant.
 	if awserr.Code() == "UnsupportedOperation" {
-		return false
+		inst.hasLatestConsoleOutput = false
+		return nil, nil
 	}
 
 	// Open 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.
-	return true
+	inst.hasLatestConsoleOutput = true
+	return nil, fmt.Errorf("could not access the latest console log: %v", err)
 }
 
 // These constants represent the possible states that the command
@@ -259,10 +251,10 @@ func (inst *ec2Instance) Exec(ctx context.Context, cmd string, args []string, op
 	}
 
 	// Exec only makes sense on a running EC2 instance.
-	if awsSDK.Int64Value(result.metadata.State.Code) != EC2InstanceRunningState {
+	if awsSDK.Int64Value(result.inst.State.Code) != EC2InstanceRunningState {
 		err := fmt.Errorf(
 			"instance is not in the running state. Its current state is: %v",
-			awsSDK.StringValue(result.metadata.State.Name),
+			awsSDK.StringValue(result.inst.State.Name),
 		)
 
 		return execResult, err
diff --git a/plugin/aws/ec2InstanceConsoleOutput.go b/plugin/aws/ec2InstanceConsoleOutput.go
index cde8c7d..b798e31 100644
--- a/plugin/aws/ec2InstanceConsoleOutput.go
+++ b/plugin/aws/ec2InstanceConsoleOutput.go
@@ -3,7 +3,11 @@ package aws
 import (
 	"bytes"
 	"context"
+	"encoding/base64"
+	"time"
 
+	awsSDK "github.com/aws/aws-sdk-go/aws"
+	ec2Client "github.com/aws/aws-sdk-go/service/ec2"
 	"github.com/puppetlabs/wash/plugin"
 )
 
@@ -15,7 +19,7 @@ type ec2InstanceConsoleOutput struct {
 	latest bool
 }
 
-func newEC2InstanceConsoleOutput(inst *ec2Instance, latest bool) *ec2InstanceConsoleOutput {
+func newEC2InstanceConsoleOutput(ctx context.Context, inst *ec2Instance, latest bool) (*ec2InstanceConsoleOutput, error) {
 	cl := &ec2InstanceConsoleOutput{
 		inst:   inst,
 		latest: latest,
@@ -28,25 +32,83 @@ func newEC2InstanceConsoleOutput(inst *ec2Instance, latest bool) *ec2InstanceCon
 	}
 	cl.DisableDefaultCaching()
 
-	return cl
+	output, err := cl.cachedConsoleOutput(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(output.mtime).
+		SetMtime(output.mtime).
+		SetAtime(output.mtime).
+		SetSize(uint64(len(output.content))).
+		SetMeta(output.toMeta())
+	cl.SetInitialAttributes(attr)
+
+	cl.Sync(plugin.CtimeAttr(), "LastModified")
+	cl.Sync(plugin.MtimeAttr(), "LastModified")
+	cl.Sync(plugin.AtimeAttr(), "LastModified")
+	cl.Sync(plugin.SizeAttr(), "Size")
+
+	return cl, nil
+}
+
+type consoleOutput struct {
+	mtime   time.Time
+	content []byte
+}
+
+func (o consoleOutput) toMeta() plugin.EntryMetadata {
+	return plugin.EntryMetadata{
+		"LastModified": o.mtime,
+		"Size":         len(o.content),
+	}
 }
 
-func (cl *ec2InstanceConsoleOutput) Attr(ctx context.Context) (plugin.Attributes, error) {
-	output, err := cl.inst.cachedConsoleOutput(ctx, cl.latest)
+func (cl *ec2InstanceConsoleOutput) cachedConsoleOutput(ctx context.Context) (consoleOutput, error) {
+	output, err := plugin.CachedOp("ConsoleOutput", cl, 30*time.Second, func() (interface{}, error) {
+		request := &ec2Client.GetConsoleOutputInput{
+			InstanceId: awsSDK.String(cl.inst.Name()),
+		}
+		if cl.latest {
+			request.Latest = awsSDK.Bool(cl.latest)
+		}
+
+		resp, err := cl.inst.client.GetConsoleOutputWithContext(ctx, request)
+		if err != nil {
+			return nil, err
+		}
+
+		content, err := base64.StdEncoding.DecodeString(awsSDK.StringValue(resp.Output))
+		if err != nil {
+			return nil, err
+		}
+
+		return consoleOutput{
+			mtime:   awsSDK.TimeValue(resp.Timestamp),
+			content: content,
+		}, nil
+	})
+
+	if err != nil {
+		return consoleOutput{}, err
+	}
+
+	return output.(consoleOutput), nil
+}
+
+func (cl *ec2InstanceConsoleOutput) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
+	output, err := cl.cachedConsoleOutput(ctx)
 	if err != nil {
-		return plugin.Attributes{}, err
+		return nil, err
 	}
 
-	return plugin.Attributes{
-		Ctime: output.mtime,
-		Mtime: output.mtime,
-		Atime: output.mtime,
-		Size:  uint64(len(output.content)),
-	}, nil
+	return output.toMeta(), nil
 }
 
 func (cl *ec2InstanceConsoleOutput) Open(ctx context.Context) (plugin.SizedReader, error) {
-	output, err := cl.inst.cachedConsoleOutput(ctx, cl.latest)
+	output, err := cl.cachedConsoleOutput(ctx)
 	if err != nil {
 		return nil, err
 	}
diff --git a/plugin/aws/ec2InstanceMetadataJSON.go b/plugin/aws/ec2InstanceMetadataJSON.go
index 59686a3..355feb0 100644
--- a/plugin/aws/ec2InstanceMetadataJSON.go
+++ b/plugin/aws/ec2InstanceMetadataJSON.go
@@ -15,26 +15,48 @@ type ec2InstanceMetadataJSON struct {
 	inst *ec2Instance
 }
 
-func newEC2InstanceMetadataJSON(inst *ec2Instance) *ec2InstanceMetadataJSON {
+func newEC2InstanceMetadataJSON(ctx context.Context, inst *ec2Instance) (*ec2InstanceMetadataJSON, error) {
 	im := &ec2InstanceMetadataJSON{
 		EntryBase: plugin.NewEntry("metadata.json"),
 		inst:      inst,
 	}
 	im.DisableDefaultCaching()
 
-	return im
+	meta, err := im.Metadata(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	attr := plugin.EntryAttributes{}
+	attr.SetSize(uint64(meta["Size"].(int64)))
+	attr.SetMeta(meta)
+	im.SetInitialAttributes(attr)
+	im.Sync(plugin.SizeAttr(), "Size")
+
+	return im, nil
+}
+
+func (im *ec2InstanceMetadataJSON) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
+	content, err := im.Open(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return plugin.EntryMetadata{
+		"Size": content.Size(),
+	}, nil
 }
 
 func (im *ec2InstanceMetadataJSON) Open(ctx context.Context) (plugin.SizedReader, error) {
-	metadata, err := plugin.CachedMetadata(ctx, im.inst)
+	meta, err := plugin.CachedMetadata(ctx, im.inst)
 	if err != nil {
 		return nil, err
 	}
 
-	content, err := json.MarshalIndent(metadata, "", "  ")
+	prettyMeta, err := json.MarshalIndent(meta, "", "  ")
 	if err != nil {
 		return nil, err
 	}
 
-	return bytes.NewReader(content), nil
+	return bytes.NewReader(prettyMeta), nil
 }
diff --git a/plugin/aws/ec2InstancesDir.go b/plugin/aws/ec2InstancesDir.go
index 54cd7b7..8a1f958 100644
--- a/plugin/aws/ec2InstancesDir.go
+++ b/plugin/aws/ec2InstancesDir.go
@@ -52,7 +52,7 @@ func (is *ec2InstancesDir) List(ctx context.Context) ([]plugin.Entry, error) {
 		for i, instance := range reservation.Instances {
 			instances[i] = newEC2Instance(
 				ctx,
-				awsSDK.StringValue(instance.InstanceId),
+				instance,
 				is.session,
 				is.client,
 			)
diff --git a/plugin/aws/root.go b/plugin/aws/root.go
index da7237e..6c5b605 100644
--- a/plugin/aws/root.go
+++ b/plugin/aws/root.go
@@ -75,7 +75,7 @@ func exists(path string) error {
 // Init for root
 func (r *Root) Init() error {
 	r.EntryBase = plugin.NewEntry("aws")
-	r.SetTTLOf(plugin.List, 1*time.Minute)
+	r.SetTTLOf(plugin.ListOp, 1*time.Minute)
 
 	// Force authorizing profiles on startup
 	_, err := r.List(context.Background())
diff --git a/plugin/aws/s3Bucket.go b/plugin/aws/s3Bucket.go
index 5d3bb78..fea8340 100644
--- a/plugin/aws/s3Bucket.go
+++ b/plugin/aws/s3Bucket.go
@@ -137,7 +137,7 @@ func listObjects(ctx context.Context, client *s3Client.S3, bucket string, prefix
 			// key == <prefix> so skip it. This is what the AWS console does.
 			continue
 		}
-		entries = append(entries, newS3Object(name, bucket, key, client))
+		entries = append(entries, newS3Object(o, name, bucket, key, client))
 	}
 
 	return entries, nil
@@ -159,15 +159,14 @@ func newS3Bucket(name string, ctime time.Time, session *session.Session) *s3Buck
 		session:   session,
 	}
 
-	return bucket
-}
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(bucket.ctime).
+		SetMtime(bucket.ctime).
+		SetAtime(bucket.ctime)
+	bucket.SetInitialAttributes(attr)
 
-func (b *s3Bucket) Attr(ctx context.Context) (plugin.Attributes, error) {
-	return plugin.Attributes{
-		Ctime: b.ctime,
-		Mtime: b.ctime,
-		Atime: b.ctime,
-	}, nil
+	return bucket
 }
 
 func (b *s3Bucket) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -177,16 +176,16 @@ func (b *s3Bucket) List(ctx context.Context) ([]plugin.Entry, error) {
 	return listObjects(ctx, b.client, b.Name(), "")
 }
 
-func (b *s3Bucket) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
+func (b *s3Bucket) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
 	request := &s3Client.GetBucketTaggingInput{
 		Bucket: awsSDK.String(b.Name()),
 	}
 
 	resp, err := b.client.GetBucketTaggingWithContext(ctx, request)
 
-	var metadata plugin.MetadataMap
+	var metadata plugin.EntryMetadata
 	if err == nil {
-		metadata = plugin.ToMetadata(resp)
+		metadata = plugin.ToMeta(resp)
 	} else if awserr, ok := err.(awserr.Error); ok {
 		// Check if this is a NoSuchTagSet error. If yes, then that means
 		// this bucket doesn't have any tags.
@@ -195,7 +194,7 @@ func (b *s3Bucket) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
 		// if you're interested in knowing why AWS does not return
 		// an empty TagSet instead of a NoSuchTagSet error
 		if awserr.Code() == "NoSuchTagSet" {
-			metadata = plugin.MetadataMap{}
+			metadata = plugin.EntryMetadata{}
 		} else {
 			return nil, err
 		}
@@ -209,6 +208,7 @@ func (b *s3Bucket) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
 		return nil, err
 	}
 	metadata["region"] = region
+	metadata["ctime"] = b.ctime
 
 	return metadata, nil
 }
diff --git a/plugin/aws/s3Object.go b/plugin/aws/s3Object.go
index d464549..5d43f51 100644
--- a/plugin/aws/s3Object.go
+++ b/plugin/aws/s3Object.go
@@ -3,7 +3,6 @@ package aws
 import (
 	"context"
 	"errors"
-	"fmt"
 	"io"
 	"strconv"
 	"time"
@@ -23,16 +22,38 @@ type s3Object struct {
 	client *s3Client.S3
 }
 
-func newS3Object(name string, bucket string, key string, client *s3Client.S3) *s3Object {
-	o := &s3Object{
+func newS3Object(o *s3Client.Object, name string, bucket string, key string, client *s3Client.S3) *s3Object {
+	s3Obj := &s3Object{
 		EntryBase: plugin.NewEntry(name),
 		bucket:    bucket,
 		key:       key,
 		client:    client,
 	}
-	o.DisableCachingFor(plugin.Metadata)
-
-	return o
+	s3Obj.DisableCachingFor(plugin.MetadataOp)
+
+	// S3 objects do not have a "creation time"; they're treated as atomic
+	// blobs that get replaced whenever the user uploads new data. Thus, we
+	// use the mtime as its creation date. See https://stackoverflow.com/questions/27746760/how-do-i-get-the-s3-keys-created-date-with-boto
+	// for more details.
+	//
+	// TODO: Export a mungeSize helper to abstract away the common
+	// logic of validating a negative size
+	mtime := awsSDK.TimeValue(o.LastModified)
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(mtime).
+		SetMtime(mtime).
+		SetAtime(mtime).
+		SetSize(uint64(awsSDK.Int64Value(o.Size)))
+	s3Obj.SetInitialAttributes(attr)
+
+	s3Obj.
+		Sync(plugin.CtimeAttr(), "LastModified").
+		Sync(plugin.MtimeAttr(), "LastModified").
+		Sync(plugin.AtimeAttr(), "LastModified").
+		Sync(plugin.SizeAttr(), "ContentLength")
+
+	return s3Obj
 }
 
 func (o *s3Object) cachedHeadObject(ctx context.Context) (*s3Client.HeadObjectOutput, error) {
@@ -52,35 +73,13 @@ func (o *s3Object) cachedHeadObject(ctx context.Context) (*s3Client.HeadObjectOu
 	return resp.(*s3Client.HeadObjectOutput), nil
 }
 
-func (o *s3Object) Attr(ctx context.Context) (plugin.Attributes, error) {
-	metadata, err := o.cachedHeadObject(ctx)
-	if err != nil {
-		return plugin.Attributes{}, err
-	}
-
-	size := awsSDK.Int64Value(metadata.ContentLength)
-	if size < 0 {
-		err := fmt.Errorf("got a negative value of %v for the size of the %v object's content", size, o.key)
-		return plugin.Attributes{}, err
-	}
-
-	mtime := awsSDK.TimeValue(metadata.LastModified)
-
-	return plugin.Attributes{
-		Ctime: mtime,
-		Mtime: mtime,
-		Atime: mtime,
-		Size:  uint64(size),
-	}, nil
-}
-
-func (o *s3Object) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
+func (o *s3Object) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
 	metadata, err := o.cachedHeadObject(ctx)
 	if err != nil {
 		return nil, nil
 	}
 
-	return plugin.ToMetadata(metadata), nil
+	return plugin.ToMeta(metadata), nil
 }
 
 func (o *s3Object) fetchContent(off int64) (io.ReadCloser, error) {
@@ -99,12 +98,7 @@ func (o *s3Object) fetchContent(off int64) (io.ReadCloser, error) {
 }
 
 func (o *s3Object) Open(ctx context.Context) (plugin.SizedReader, error) {
-	attr, err := o.Attr(ctx)
-	if err != nil {
-		return nil, err
-	}
-
-	return &s3ObjectReader{o: o, size: int64(attr.Size)}, nil
+	return &s3ObjectReader{o: o}, nil
 }
 
 func (o *s3Object) Stream(context.Context) (io.Reader, error) {
@@ -120,9 +114,6 @@ func (o *s3Object) Stream(context.Context) (io.Reader, error) {
 // art we could use to optimize this.
 type s3ObjectReader struct {
 	o *s3Object
-	// See the comments in s3ObjectReader#Size to understand how this field's
-	// used.
-	size int64
 }
 
 func (s *s3ObjectReader) closeContent(content io.ReadCloser) {
@@ -150,18 +141,6 @@ func (s *s3ObjectReader) ReadAt(p []byte, off int64) (int, error) {
 }
 
 func (s *s3ObjectReader) Size() int64 {
-	ctx, cancelFunc := context.WithTimeout(context.Background(), 2*time.Second)
-	defer cancelFunc()
-
-	// We'd like to return s.o.Attr().Size here. Unfortunately since
-	// s.o.Attr() is calculated via. an API request, there's a chance
-	// that it could error. If that happens, we return s.size instead
-	// as a fallback. We could change Size()'s return signature to
-	// (int64, error), but there's no good reason to do that right now.
-	attr, err := s.o.Attr(ctx)
-	if err != nil {
-		return s.size
-	}
-
-	return int64(attr.Size)
+	attr := plugin.Attributes(s.o)
+	return int64(attr.Size())
 }
diff --git a/plugin/cache.go b/plugin/cache.go
index 40d9c4a..1d87dbc 100644
--- a/plugin/cache.go
+++ b/plugin/cache.go
@@ -93,7 +93,7 @@ func CachedOp(opName string, entry Entry, ttl time.Duration, op opFunc) (interfa
 		panic(fmt.Sprintf("The opName %v does not match %v", opName, opNameRegex.String()))
 	}
 
-	for _, actionOpName := range actionOpCodeToNameMap {
+	for _, actionOpName := range defaultOpCodeToNameMap {
 		if opName == actionOpName {
 			panic(fmt.Sprintf("The opName %v conflicts with Cached%v", opName, actionOpName))
 		}
@@ -137,7 +137,7 @@ func (c DuplicateCNameErr) Error() string {
 // CachedList caches a Group object's List method. It also sets the
 // children's IDs to <parent_id> + "/" + <child_cname>.
 func CachedList(ctx context.Context, g Group) ([]Entry, error) {
-	cachedEntries, err := cachedActionOp(List, g, func() (interface{}, error) {
+	cachedEntries, err := cachedDefaultOp(ListOp, g, func() (interface{}, error) {
 		entries, err := g.List(ctx)
 		if err != nil {
 			return nil, err
@@ -150,9 +150,9 @@ func CachedList(ctx context.Context, g Group) ([]Entry, error) {
 			if duplicateEntry, ok := searchedEntries[cname]; ok {
 				return nil, DuplicateCNameErr{
 					ParentPath:                      g.id(),
-					FirstChildName:                  duplicateEntry.Name(),
+					FirstChildName:                  duplicateEntry.name(),
 					FirstChildSlashReplacementChar:  duplicateEntry.slashReplacementChar(),
-					SecondChildName:                 entry.Name(),
+					SecondChildName:                 entry.name(),
 					SecondChildSlashReplacementChar: entry.slashReplacementChar(),
 					CName:                           cname,
 				}
@@ -178,7 +178,7 @@ func CachedList(ctx context.Context, g Group) ([]Entry, error) {
 // 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 := cachedActionOp(Open, r, func() (interface{}, error) {
+	cachedContent, err := cachedDefaultOp(OpenOp, r, func() (interface{}, error) {
 		return r.Open(ctx)
 	})
 
@@ -189,28 +189,28 @@ func CachedOpen(ctx context.Context, r Readable) (SizedReader, error) {
 	return cachedContent.(SizedReader), nil
 }
 
-// CachedMetadata caches a Resource object's Metadata method
-func CachedMetadata(ctx context.Context, r Resource) (MetadataMap, error) {
-	cachedMetadata, err := cachedActionOp(Metadata, r, func() (interface{}, error) {
-		return r.Metadata(ctx)
+// CachedMetadata caches an entry's Metadata method
+func CachedMetadata(ctx context.Context, e Entry) (EntryMetadata, error) {
+	cachedMetadata, err := cachedDefaultOp(MetadataOp, e, func() (interface{}, error) {
+		return e.Metadata(ctx)
 	})
 
 	if err != nil {
 		return nil, err
 	}
 
-	return cachedMetadata.(MetadataMap), nil
+	return cachedMetadata.(EntryMetadata), nil
 }
 
 // Common helper for CachedList, CachedOpen and CachedMetadata
-func cachedActionOp(opCode actionOpCode, entry Entry, op opFunc) (interface{}, error) {
-	opName := actionOpCodeToNameMap[opCode]
+func cachedDefaultOp(opCode defaultOpCode, entry Entry, op opFunc) (interface{}, error) {
+	opName := defaultOpCodeToNameMap[opCode]
 	ttl := entry.getTTLOf(opCode)
 
 	return cachedOp(opName, entry, ttl, op)
 }
 
-// Common helper for CachedOp and cachedActionOp.
+// Common helper for CachedOp and cachedDefaultOp.
 func cachedOp(opName string, entry Entry, ttl time.Duration, op opFunc) (interface{}, error) {
 	if cache == nil {
 		if notRunningTests() {
@@ -221,9 +221,13 @@ func cachedOp(opName string, entry Entry, ttl time.Duration, op opFunc) (interfa
 	}
 
 	if entry.id() == "" {
-		// The Registry's ID is set to "/", while all other entries' IDs are
-		// set in CachedList. Thus, we should never hit this code-path.
-		panic("entry.id() returned an empty ID")
+		// This can happen if the entry's just been created, and it calls one of the Cached*
+		// methods to query its Metadata so that it can set its initial attributes. In that case,
+		// just invoke op. Ideally we should panic here, but panicking makes it significantly more
+		// annoying for plugin authors to write their code. Since the plugin package manages the
+		// entry's ID, and the entry's ID is set in CachedList, an empty ID is such a rare edge case
+		// that it's better to not annoy people.
+		return op()
 	}
 
 	if ttl < 0 {
diff --git a/plugin/docker/container-log.go b/plugin/docker/container-log.go
index 439f4ac..2b20ff7 100644
--- a/plugin/docker/container-log.go
+++ b/plugin/docker/container-log.go
@@ -28,6 +28,17 @@ func (clf *containerLogFile) isTty(ctx context.Context) bool {
 	return true
 }
 
+func (clf *containerLogFile) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
+	content, err := plugin.CachedOpen(ctx, clf)
+	if err != nil {
+		return nil, err
+	}
+
+	return plugin.EntryMetadata{
+		"Size": content.Size(),
+	}, nil
+}
+
 func (clf *containerLogFile) Open(ctx context.Context) (plugin.SizedReader, error) {
 	opts := types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}
 	rdr, err := clf.client.ContainerLogs(ctx, clf.containerName, opts)
diff --git a/plugin/docker/container-metadata.go b/plugin/docker/container-metadata.go
index 8b17df9..85bcfd5 100644
--- a/plugin/docker/container-metadata.go
+++ b/plugin/docker/container-metadata.go
@@ -13,6 +13,17 @@ type containerMetadata struct {
 	container *container
 }
 
+func (cm *containerMetadata) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
+	content, err := cm.Open(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return plugin.EntryMetadata{
+		"Size": content.Size(),
+	}, nil
+}
+
 func (cm *containerMetadata) Open(ctx context.Context) (plugin.SizedReader, error) {
 	metadata, err := plugin.CachedMetadata(ctx, cm.container)
 	if err != nil {
diff --git a/plugin/docker/container.go b/plugin/docker/container.go
index 80bee1c..9b2ba80 100644
--- a/plugin/docker/container.go
+++ b/plugin/docker/container.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"time"
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/client"
@@ -15,35 +14,43 @@ import (
 
 type container struct {
 	plugin.EntryBase
-	client    *client.Client
-	startTime time.Time
+	client *client.Client
 }
 
-// Metadata
-func (c *container) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
+func (c *container) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
 	// Use raw to also get the container size.
 	_, raw, err := c.client.ContainerInspectWithRaw(ctx, c.Name(), true)
 	if err != nil {
 		return nil, err
 	}
 
-	return plugin.ToMetadata(raw), nil
-}
-
-// Attr
-func (c *container) Attr(ctx context.Context) (plugin.Attributes, error) {
-	return plugin.Attributes{
-		Ctime: c.startTime,
-		Mtime: c.startTime,
-		Atime: c.startTime,
-	}, nil
+	return plugin.ToMeta(raw), nil
 }
 
 func (c *container) List(ctx context.Context) ([]plugin.Entry, error) {
+	// TODO: May be worth creating a helper that makes it easy to create
+	// read-only files. Lots of shared code between these two.
 	cm := &containerMetadata{plugin.NewEntry("metadata.json"), c}
 	cm.DisableDefaultCaching()
+	meta, err := cm.Metadata(ctx)
+	if err != nil {
+		return nil, err
+	}
+	cmAttr := plugin.EntryAttributes{}
+	cmAttr.SetSize(uint64(meta["Size"].(int64)))
+	cm.SetInitialAttributes(cmAttr)
+	cm.Sync(plugin.SizeAttr(), "Size")
 
 	clf := &containerLogFile{plugin.NewEntry("log"), c.Name(), c.client}
+	clf.DisableCachingFor(plugin.MetadataOp)
+	meta, err = clf.Metadata(ctx)
+	if err != nil {
+		return nil, err
+	}
+	clfAttr := plugin.EntryAttributes{}
+	clfAttr.SetSize(uint64(meta["Size"].(int64)))
+	clf.SetInitialAttributes(clfAttr)
+	clf.Sync(plugin.SizeAttr(), "Size")
 
 	return []plugin.Entry{cm, clf}, nil
 }
diff --git a/plugin/docker/containers.go b/plugin/docker/containers.go
index 8cf7e6e..8d0a81c 100644
--- a/plugin/docker/containers.go
+++ b/plugin/docker/containers.go
@@ -25,11 +25,20 @@ func (cs *containers) List(ctx context.Context) ([]plugin.Entry, error) {
 	journal.Record(ctx, "Listing %v containers in %v", len(containers), cs)
 	keys := make([]plugin.Entry, len(containers))
 	for i, inst := range containers {
-		keys[i] = &container{
+		cont := &container{
 			EntryBase: plugin.NewEntry(inst.ID),
 			client:    cs.client,
-			startTime: time.Unix(inst.Created, 0),
 		}
+
+		startTime := time.Unix(inst.Created, 0)
+		attr := plugin.EntryAttributes{}
+		attr.
+			SetCtime(startTime).
+			SetMtime(startTime).
+			SetAtime(startTime)
+		cont.SetInitialAttributes(attr)
+
+		keys[i] = cont
 	}
 	return keys, nil
 }
diff --git a/plugin/docker/volume.go b/plugin/docker/volume.go
index ff2eaaa..754d87b 100644
--- a/plugin/docker/volume.go
+++ b/plugin/docker/volume.go
@@ -20,8 +20,7 @@ import (
 
 type volume struct {
 	plugin.EntryBase
-	client    *client.Client
-	startTime time.Time
+	client *client.Client
 }
 
 const mountpoint = "/mnt"
@@ -35,27 +34,26 @@ func newVolume(c *client.Client, v *types.Volume) (*volume, error) {
 	vol := &volume{
 		EntryBase: plugin.NewEntry(v.Name),
 		client:    c,
-		startTime: startTime,
 	}
-	vol.SetTTLOf(plugin.List, 60*time.Second)
+	vol.SetTTLOf(plugin.ListOp, 60*time.Second)
+
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(startTime).
+		SetMtime(startTime).
+		SetAtime(startTime).
+		SetMeta(plugin.ToMeta(v))
+	vol.SetInitialAttributes(attr)
 
 	return vol, nil
 }
 
-func (v *volume) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
+func (v *volume) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
 	_, raw, err := v.client.VolumeInspectWithRaw(ctx, v.Name())
 	if err != nil {
 		return nil, err
 	}
-	return plugin.ToMetadata(raw), nil
-}
-
-func (v *volume) Attr(ctx context.Context) (plugin.Attributes, error) {
-	return plugin.Attributes{
-		Ctime: v.startTime,
-		Mtime: v.startTime,
-		Atime: v.startTime,
-	}, nil
+	return plugin.ToMeta(raw), nil
 }
 
 func (v *volume) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -115,7 +113,7 @@ func (v *volume) List(ctx context.Context) ([]plugin.Entry, error) {
 	root := dirs[""]
 	entries := make([]plugin.Entry, 0, len(root))
 	for name, attr := range root {
-		if attr.Mode.IsDir() {
+		if attr.Mode().IsDir() {
 			entries = append(entries, vol.NewDir(name, attr, v.getContentCB(), "/"+name, dirs))
 		} else {
 			entries = append(entries, vol.NewFile(name, attr, v.getContentCB(), "/"+name))
diff --git a/plugin/entryAttributes.go b/plugin/entryAttributes.go
new file mode 100644
index 0000000..6a98474
--- /dev/null
+++ b/plugin/entryAttributes.go
@@ -0,0 +1,199 @@
+package plugin
+
+import (
+	"encoding/json"
+	"os"
+	"time"
+
+	"github.com/Benchkram/errz"
+)
+
+// EntryMetadata represents the entry's metadata
+// as returned by Entry#Metadata.
+type EntryMetadata map[string]interface{}
+
+// ToMeta converts an object to an EntryMetadata object. If the input is already an
+// array of bytes, it must contain a serialized JSON object. Will panic if given something
+// besides a struct or []byte.
+func ToMeta(obj interface{}) EntryMetadata {
+	var err error
+	var inrec []byte
+	if arr, ok := obj.([]byte); ok {
+		inrec = arr
+	} else {
+		if inrec, err = json.Marshal(obj); err != nil {
+			// Internal error if we can't marshal an object
+			panic(err)
+		}
+	}
+	var meta EntryMetadata
+	// Internal error if not a JSON object
+	errz.Fatal(json.Unmarshal(inrec, &meta))
+	return meta
+}
+
+/*
+EntryAttributes represents an entry's attributes. We use a struct
+instead of a map for efficient memory allocation/deallocation,
+which is needed to make Group#List fast.
+
+Each of the setters supports the builder pattern, which enables you
+to do something like
+
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(ctime).
+		SetMtime(mtime).
+		SetMeta(meta)
+	entry.SetInitialAttributes(attr)
+*/
+type EntryAttributes struct {
+	atime   time.Time
+	mtime   time.Time
+	ctime   time.Time
+	mode    os.FileMode
+	hasMode bool
+	size    uint64
+	hasSize bool
+	meta    EntryMetadata
+}
+
+// We can't just export EntryAttributes' fields because there's no way
+// to determine if an arbitrary entry has e.g. a 'size' attribute from
+// the size value alone (since 0-size is valid). That's why we have the
+// separate has* fields, and that's why those attributes need their own
+// setters. However, it's a bit weird to have setters for some fields
+// and not have setters for others (e.g. we could export atime, mtime,
+// ctime b/c we know that an entry has atime/mtime/ctime if their value
+// isn't the zero-time). It also increases the chance that a plugin author
+// could inadvertantly forget to call the `size`/`mode` attribute setter
+// when creating their attributes and instead set those values in the
+// constructor (via something like EntryAttributes{Ctime: time.Now(), Size: 15}).
+// The latter's bad b/c Wash would think the entry didn't have a size attribute
+// (since hasSize is false).
+//
+// Thus, although these getters/setters/Has* methods are annoying, they're
+// the best way to maintain a clean and consistent interface for EntryAttributes
+// while minimizing plugin author error.
+
+// HasAtime returns true if the entry has a last access time
+func (a *EntryAttributes) HasAtime() bool {
+	return !a.atime.IsZero()
+}
+
+// Atime returns the entry's last access time
+func (a *EntryAttributes) Atime() time.Time {
+	return a.atime
+}
+
+// SetAtime sets the entry's last access time
+func (a *EntryAttributes) SetAtime(atime time.Time) *EntryAttributes {
+	a.atime = atime
+	return a
+}
+
+// HasMtime returns true if the entry has a last modified time
+func (a *EntryAttributes) HasMtime() bool {
+	return !a.mtime.IsZero()
+}
+
+// Mtime returns the entry's last modified time
+func (a *EntryAttributes) Mtime() time.Time {
+	return a.mtime
+}
+
+// SetMtime sets the entry's last modified time
+func (a *EntryAttributes) SetMtime(mtime time.Time) *EntryAttributes {
+	a.mtime = mtime
+	return a
+}
+
+// HasCtime returns true if the entry has a creation time
+func (a *EntryAttributes) HasCtime() bool {
+	return !a.ctime.IsZero()
+}
+
+// Ctime returns the entry's creation time
+func (a *EntryAttributes) Ctime() time.Time {
+	return a.ctime
+}
+
+// SetCtime sets the entry's creation time
+func (a *EntryAttributes) SetCtime(ctime time.Time) *EntryAttributes {
+	a.ctime = ctime
+	return a
+}
+
+// HasMode returns true if the entry has a mode
+func (a *EntryAttributes) HasMode() bool {
+	return a.hasMode
+}
+
+// Mode returns the entry's mode
+func (a *EntryAttributes) Mode() os.FileMode {
+	return a.mode
+}
+
+// SetMode sets the entry's mode
+func (a *EntryAttributes) SetMode(mode os.FileMode) *EntryAttributes {
+	a.mode = mode
+	a.hasMode = true
+	return a
+}
+
+// HasSize returns true if the entry has a size
+func (a *EntryAttributes) HasSize() bool {
+	return a.hasSize
+}
+
+// Size returns the entry's Size
+func (a *EntryAttributes) Size() uint64 {
+	return a.size
+}
+
+// SetSize sets the entry's size
+func (a *EntryAttributes) SetSize(size uint64) *EntryAttributes {
+	a.size = size
+	a.hasSize = true
+	return a
+}
+
+// Meta returns the entry's metadata. All entries have a
+// meta attribute, which by default is an empty map.
+func (a *EntryAttributes) Meta() EntryMetadata {
+	if a.meta == nil {
+		return EntryMetadata{}
+	}
+
+	return a.meta
+}
+
+// SetMeta sets the entry's metadata
+func (a *EntryAttributes) SetMeta(meta EntryMetadata) *EntryAttributes {
+	a.meta = meta
+	return a
+}
+
+// This helper's useful for testing.
+func (a *EntryAttributes) toMap() map[string]interface{} {
+	mp := make(map[string]interface{})
+
+	if a.HasAtime() {
+		mp[AtimeAttr().name] = a.Atime()
+	}
+	if a.HasMtime() {
+		mp[MtimeAttr().name] = a.Mtime()
+	}
+	if a.HasCtime() {
+		mp[CtimeAttr().name] = a.Ctime()
+	}
+	if a.HasMode() {
+		mp[ModeAttr().name] = a.Mode()
+	}
+	if a.HasSize() {
+		mp[SizeAttr().name] = a.Size()
+	}
+	mp["meta"] = a.Meta()
+
+	return mp
+}
diff --git a/plugin/entryBase.go b/plugin/entryBase.go
index bae79f9..1afaea6 100644
--- a/plugin/entryBase.go
+++ b/plugin/entryBase.go
@@ -3,76 +3,80 @@ package plugin
 import (
 	"context"
 	"flag"
+	"fmt"
+	"sync"
 	"time"
 )
 
-type actionOpCode int8
+type defaultOpCode int8
 
 const (
-	// List represents Group#List
-	List actionOpCode = iota
-	// Open represents Readable#Open
-	Open
-	// Metadata represents Resource#Metadata
-	Metadata
+	// ListOp represents Group#List
+	ListOp defaultOpCode = iota
+	// OpenOp represents Readable#Open
+	OpenOp
+	// MetadataOp represents Entry#Metadata
+	MetadataOp
 )
 
-var actionOpCodeToNameMap = [3]string{"List", "Open", "Metadata"}
+var defaultOpCodeToNameMap = [3]string{"List", "Open", "Metadata"}
+
+type syncedAttribute struct {
+	*SyncableAttribute
+	key string
+}
 
 // EntryBase implements Entry, making it easy to create new entries.
 // You should use plugin.NewEntry to create new EntryBase objects.
 type EntryBase struct {
-	name               string
+	entryName          string
+	attrMux            *sync.RWMutex
+	attr               EntryAttributes
+	syncedAttrs        []syncedAttribute
 	slashReplacementCh rune
 	// washID represents the entry's wash ID. It is set in CachedList.
 	washID string
 	ttl    [3]time.Duration
 }
 
-// newEntryBase is needed by NewEntry, NewRegistry,
-// and some of the cache tests
-func newEntryBase(name string) EntryBase {
+// NewEntry creates a new entry
+func NewEntry(name string) EntryBase {
+	if name == "" {
+		panic("plugin.NewEntry: received an empty name")
+	}
+
 	e := EntryBase{
-		name:               name,
+		entryName:          name,
+		attrMux:            &sync.RWMutex{},
 		slashReplacementCh: '#',
 	}
-
 	for op := range e.ttl {
-		e.SetTTLOf(actionOpCode(op), 15*time.Second)
+		e.SetTTLOf(defaultOpCode(op), 15*time.Second)
 	}
-
 	return e
 }
 
-// NewEntry creates a new entry
-func NewEntry(name string) EntryBase {
-	if name == "" {
-		panic("plugin.NewEntry: received an empty name")
-	}
+// ENTRY INTERFACE
 
-	return newEntryBase(name)
-}
+// Metadata returns the entry's meta attribute (see plugin.EntryAttributes).
+// Do not override this if the entry's metadata will never change.
+func (e *EntryBase) Metadata(ctx context.Context) (EntryMetadata, error) {
+	// Disable Metadata's caching in case the plugin author forgot to do this
+	e.DisableCachingFor(MetadataOp)
 
-// ENTRY INTERFACE
+	attr := e.attributes()
+	return attr.Meta(), nil
+}
 
-// Name returns the entry's name as it was passed into
-// plugin.NewEntry. You should use e.Name() when making
-// the appropriate API calls within your plugin.
-func (e *EntryBase) Name() string {
-	return e.name
+func (e *EntryBase) name() string {
+	return e.entryName
 }
 
-// Attr returns the entry's attributes. The default return value
-// is a zero'ed attributes struct with the Size field set to
-// SizeUnknown. You should override Attr() if you'd like to return
-// a different set of attributes.
-//
-// NOTE: See the comments for plugin.Attr if you're interested in
-// knowing why the Size field is set to SizeUnknown.
-func (e *EntryBase) Attr(ctx context.Context) (Attributes, error) {
-	return Attributes{
-		Size: SizeUnknown,
-	}, nil
+func (e *EntryBase) attributes() EntryAttributes {
+	e.attrMux.RLock()
+	defer e.attrMux.RUnlock()
+
+	return e.attr
 }
 
 func (e *EntryBase) slashReplacementChar() rune {
@@ -87,13 +91,54 @@ func (e *EntryBase) setID(id string) {
 	e.washID = id
 }
 
-func (e *EntryBase) getTTLOf(op actionOpCode) time.Duration {
+func (e *EntryBase) getTTLOf(op defaultOpCode) time.Duration {
 	return e.ttl[op]
 }
 
 // OTHER METHODS USED TO FACILITATE PLUGIN DEVELOPMENT
 // AND TESTING
 
+// Name returns the entry's name as it was passed into
+// plugin.NewEntry. You should use e.Name() when making
+// the appropriate API calls within your plugin.
+func (e *EntryBase) Name() string {
+	return e.name()
+}
+
+// SetInitialAttributes sets the entry's initial attributes. Use it
+// after creating the entry via a call to NewEntry.
+func (e *EntryBase) SetInitialAttributes(attr EntryAttributes) {
+	e.attr = attr
+}
+
+/*
+Sync syncs the attribute with the given key's value in the entry's
+metadata whenever the latter's refreshed via a call to plugin.CachedMetadata.
+Use Sync if you expect the attribute to change. It returns the entry, e,
+to facilitate the builder pattern, which lets you do something like
+
+	entry.
+		Sync(plugin.CtimeAttr(), "LastModified").
+		Sync(plugin.MtimeAttr(), "LastModified").
+		Sync(plugin.AtimeAttr(), "LastModified").
+		Sync(plugin.SizeAttr(), "ContentLength")
+
+TODO: Find a way to let plugin authors specify a munging function. We'll
+need this for the State attribute, and in case the metadata returns some of
+the other attributes in a different format (e.g. like an arbitrarily formatted
+string for one of the time fields)
+*/
+func (e *EntryBase) Sync(attr *SyncableAttribute, key string) *EntryBase {
+	// We use an array instead of a map because the latter takes up a lot of
+	// memory (~40 bytes for an empty map). That adds up quick when there's
+	// hundreds of thousands of entries.
+	e.syncedAttrs = append(
+		e.syncedAttrs,
+		syncedAttribute{attr, key},
+	)
+	return e
+}
+
 /*
 SetSlashReplacementChar overrides the default '/' replacement
 character of '#' to char. The '/' replacement character is used
@@ -109,12 +154,12 @@ func (e *EntryBase) SetSlashReplacementChar(char rune) {
 }
 
 // SetTTLOf sets the specified op's TTL
-func (e *EntryBase) SetTTLOf(op actionOpCode, ttl time.Duration) {
+func (e *EntryBase) SetTTLOf(op defaultOpCode, ttl time.Duration) {
 	e.ttl[op] = ttl
 }
 
 // DisableCachingFor disables caching for the specified op
-func (e *EntryBase) DisableCachingFor(op actionOpCode) {
+func (e *EntryBase) DisableCachingFor(op defaultOpCode) {
 	e.SetTTLOf(op, -1)
 }
 
@@ -122,7 +167,7 @@ func (e *EntryBase) DisableCachingFor(op actionOpCode) {
 // for List, Open and Metadata.
 func (e *EntryBase) DisableDefaultCaching() {
 	for op := range e.ttl {
-		e.DisableCachingFor(actionOpCode(op))
+		e.DisableCachingFor(defaultOpCode(op))
 	}
 }
 
@@ -136,6 +181,34 @@ func (e *EntryBase) SetTestID(id string) {
 	e.setID(id)
 }
 
+func (e *EntryBase) hasSyncedAttributes() bool {
+	return len(e.syncedAttrs) > 0
+}
+
+func (e *EntryBase) syncAttributesWith(meta EntryMetadata) error {
+	e.attrMux.Lock()
+	defer e.attrMux.Unlock()
+
+	var allErrs error
+	for _, attr := range e.syncedAttrs {
+		err := attr.sync(e, meta, attr.key)
+		if err != nil {
+			err := fmt.Errorf("failed to sync the %v attribute: %v", attr.name, err)
+			if allErrs != nil {
+				allErrs = fmt.Errorf("%v; %v", allErrs, err)
+			} else {
+				allErrs = err
+			}
+		}
+	}
+	if allErrs != nil {
+		return allErrs
+	}
+	e.attr.SetMeta(meta)
+
+	return nil
+}
+
 func notRunningTests() bool {
 	return flag.Lookup("test.v") == nil
 }
diff --git a/plugin/externalPluginEntry.go b/plugin/externalPluginEntry.go
index 2408ac9..3baa28f 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/externalPluginEntry.go
@@ -15,6 +15,9 @@ import (
 	"github.com/puppetlabs/wash/journal"
 )
 
+/*
+TODO: Come back and rethink attributes once all the attributes/metadata work is finished.
+*/
 type decodedAttributes struct {
 	// Atime, Mtime, and Ctime are in Unix time
 	Atime int64         `json:"atime"`
@@ -33,6 +36,7 @@ func unixSecondsToTimeAttr(seconds int64) time.Time {
 	return time.Unix(seconds, 0)
 }
 
+/*
 func (a decodedAttributes) toAttributes() (Attributes, error) {
 	attr := Attributes{
 		Atime: unixSecondsToTimeAttr(a.Atime),
@@ -52,6 +56,7 @@ func (a decodedAttributes) toAttributes() (Attributes, error) {
 
 	return attr, nil
 }
+*/
 
 type decodedCacheTTLs struct {
 	List     time.Duration `json:"list"`
@@ -77,16 +82,10 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry() (*externalPluginEntr
 		return nil, fmt.Errorf("the entry's supported actions must be provided")
 	}
 
-	attr, err := e.Attributes.toAttributes()
-	if err != nil {
-		return nil, err
-	}
-
 	entry := &externalPluginEntry{
 		EntryBase:        NewEntry(e.Name),
 		supportedActions: e.SupportedActions,
 		state:            e.State,
-		attr:             attr,
 	}
 	entry.setCacheTTLs(e.CacheTTLs)
 	if e.SlashReplacementChar != "" {
@@ -109,18 +108,17 @@ type externalPluginEntry struct {
 	script           externalPluginScript
 	supportedActions []string
 	state            string
-	attr             Attributes
 }
 
 func (e *externalPluginEntry) setCacheTTLs(ttls decodedCacheTTLs) {
 	if ttls.List != 0 {
-		e.SetTTLOf(List, ttls.List*time.Second)
+		e.SetTTLOf(ListOp, ttls.List*time.Second)
 	}
 	if ttls.Open != 0 {
-		e.SetTTLOf(Open, ttls.Open*time.Second)
+		e.SetTTLOf(OpenOp, ttls.Open*time.Second)
 	}
 	if ttls.Metadata != 0 {
-		e.SetTTLOf(Metadata, ttls.Metadata*time.Second)
+		e.SetTTLOf(MetadataOp, ttls.Metadata*time.Second)
 	}
 }
 
@@ -167,14 +165,14 @@ func (e *externalPluginEntry) Open(ctx context.Context) (SizedReader, error) {
 	return bytes.NewReader(stdout), nil
 }
 
-// Metadata displays the resource's metadata
-func (e *externalPluginEntry) Metadata(ctx context.Context) (MetadataMap, error) {
+// Metadata displays the entry's metadata
+func (e *externalPluginEntry) Metadata(ctx context.Context) (EntryMetadata, error) {
 	stdout, err := e.script.InvokeAndWait(ctx, "metadata", e.id(), e.state)
 	if err != nil {
 		return nil, err
 	}
 
-	var metadata MetadataMap
+	var metadata EntryMetadata
 	if err := json.Unmarshal(stdout, &metadata); err != nil {
 		journal.Record(
 			ctx,
@@ -189,11 +187,6 @@ func (e *externalPluginEntry) Metadata(ctx context.Context) (MetadataMap, error)
 	return metadata, nil
 }
 
-// Attr returns the entry's filesystem attributes
-func (e *externalPluginEntry) Attr(ctx context.Context) (Attributes, error) {
-	return e.attr, nil
-}
-
 type stdoutStreamer struct {
 	stdoutRdr io.ReadCloser
 	cmd       *exec.Cmd
diff --git a/plugin/helpers.go b/plugin/helpers.go
index af331b3..f42e455 100644
--- a/plugin/helpers.go
+++ b/plugin/helpers.go
@@ -2,7 +2,6 @@ package plugin
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"io"
 	"os"
@@ -12,17 +11,31 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/Benchkram/errz"
+	"github.com/puppetlabs/wash/journal"
 	log "github.com/sirupsen/logrus"
 )
 
 // DefaultTimeout is the default timeout for prefetching
 var DefaultTimeout = 10 * time.Second
 
+/*
+Name returns the entry's name as it was passed into
+plugin.NewEntry. It is meant to be called by other
+Wash packages. Plugin authors should use EntryBase#Name
+when writing their plugins.
+*/
+func Name(e Entry) string {
+	// The reason we don't expose EntryBase#Name in the Entry
+	// interface is so plugin authors don't override it. It ensures
+	// that whatever name they pass into plugin.NewEntry is the
+	// name received by Wash.
+	return e.name()
+}
+
 /*
 CName returns the entry's canonical name, which is what Wash uses
 to construct the entry's path (see plugin.Path). The entry's cname
-is e.Name(), but with all '/' characters replaced by a '#' character.
+is plugin.Name(e), but with all '/' characters replaced by a '#' character.
 CNames are necessary because it is possible for entry names to have '/'es
 in them, which is illegal in bourne shells and UNIX-y filesystems.
 
@@ -40,7 +53,7 @@ func CName(e Entry) string {
 	// in the Entry interface because doing so prevents plugin authors
 	// from overriding it.
 	return strings.Replace(
-		e.Name(),
+		e.name(),
 		"/",
 		string(e.slashReplacementChar()),
 		-1,
@@ -57,25 +70,6 @@ func Path(e Entry) string {
 	return e.id()
 }
 
-// ToMetadata converts an object to a metadata result. If the input is already an array of bytes, it
-// must contain a serialized JSON object. Will panic if given something besides a struct or []byte.
-func ToMetadata(obj interface{}) MetadataMap {
-	var err error
-	var inrec []byte
-	if arr, ok := obj.([]byte); ok {
-		inrec = arr
-	} else {
-		if inrec, err = json.Marshal(obj); err != nil {
-			// Internal error if we can't marshal an object
-			panic(err)
-		}
-	}
-	var meta MetadataMap
-	// Internal error if not a JSON object
-	errz.Fatal(json.Unmarshal(inrec, &meta))
-	return meta
-}
-
 // TrackTime helper is useful for timing functions.
 // Use with `defer plugin.TrackTime(time.Now(), "funcname")`.
 func TrackTime(start time.Time, name string) {
@@ -163,49 +157,34 @@ func ToFileMode(mode interface{}) (os.FileMode, error) {
 	return fileMode, nil
 }
 
-// Attr returns the entry's attributes in a form that's consummable by
-// FUSE and the API. This may or may not match entry.Attr(). Specifically,
-//   * If entry.Attr() sets the size field to SizeUnknown and the entry supports
-//   the Read action, then the returned attributes' size will be set to
-//   the size of the entry's content as returned by Open.
-//
-//   * If entry.Attr() does not set the mode, then the returned attributes'
-//   mode will be set to "os.ModeDir | 0550" if the entry supports the List
-//   action; otherwise, it will be set to 0440. This is needed by FUSE.
-//
-//   * Otherwise, the returned attributes will match what's returned by
-//   entry.Attr().
-//
-func Attr(ctx context.Context, entry Entry) (Attributes, error) {
-	attr, err := entry.Attr(ctx)
-	if err != nil {
-		return attr, err
-	}
+// Attributes returns the entry's attribtues
+func Attributes(e Entry) EntryAttributes {
+	return e.attributes()
+}
 
-	if attr.Size == SizeUnknown && ReadAction.IsSupportedOn(entry) {
-		content, openErr := CachedOpen(ctx, entry.(Readable))
-		if openErr != nil {
-			err = ErrCouldNotDetermineSizeAttr{openErr.Error()}
-			attr.Size = 0
-		} else {
-			size := content.Size()
-			if size < 0 {
-				return attr, ErrNegativeSizeAttr{size}
-			}
-
-			attr.Size = uint64(size)
-		}
+// RefreshAttributes refreshes the entry's attributes. It is primarily
+// needed by FUSE because FUSE nodes have a very long lifetime.
+func RefreshAttributes(ctx context.Context, e Entry) error {
+	if !e.hasSyncedAttributes() {
+		// No need to refresh if the entry doesn't have any synced
+		// attributes. This prevents an unnecessary fetch of the
+		// entry's metadata.
+		return nil
 	}
 
-	if attr.Mode == 0 {
-		if ListAction.IsSupportedOn(entry) {
-			attr.Mode = os.ModeDir | 0550
-		} else {
-			attr.Mode = 0440
-		}
-	}
+	journal.Record(ctx, "Refreshing the attributes of %v", Path(e))
 
-	return attr, err
+	// We can't put the refresh-attributes logic in EntryBase because doing
+	// so would call EntryBase#Metadata.
+	meta, err := CachedMetadata(ctx, e)
+	if err != nil {
+		return fmt.Errorf("failed to refresh the attributes: %v", err)
+	}
+	err = e.syncAttributesWith(meta)
+	if err != nil {
+		return fmt.Errorf("failed to refresh the attributes: %v", err)
+	}
+	return nil
 }
 
 // CreateCommand creates a cmd object encapsulating the given cmd and its args.
diff --git a/plugin/kubernetes/namespace.go b/plugin/kubernetes/namespace.go
index 7f3c599..7a8df79 100644
--- a/plugin/kubernetes/namespace.go
+++ b/plugin/kubernetes/namespace.go
@@ -32,10 +32,10 @@ func (n *namespace) List(ctx context.Context) ([]plugin.Entry, error) {
 	return n.resourcetypes, nil
 }
 
-func (n *namespace) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
+func (n *namespace) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
 	journal.Record(ctx, "Metadata for namespace %v: %+v", n.Name(), n.metadata)
 	if n.metadata != nil {
-		return plugin.ToMetadata(n.metadata), nil
+		return plugin.ToMeta(n.metadata), nil
 	}
-	return plugin.MetadataMap{}, nil
+	return plugin.EntryMetadata{}, nil
 }
diff --git a/plugin/kubernetes/pod.go b/plugin/kubernetes/pod.go
index a334a72..59d3963 100644
--- a/plugin/kubernetes/pod.go
+++ b/plugin/kubernetes/pod.go
@@ -19,44 +19,54 @@ import (
 
 type pod struct {
 	plugin.EntryBase
-	client    *k8s.Clientset
-	config    *rest.Config
-	ns        string
-	startTime time.Time
+	client *k8s.Clientset
+	config *rest.Config
+	ns     string
 }
 
-func newPod(client *k8s.Clientset, config *rest.Config, ns string, p *corev1.Pod) *pod {
+func newPod(ctx context.Context, client *k8s.Clientset, config *rest.Config, ns string, p *corev1.Pod) (*pod, error) {
 	pd := &pod{
 		EntryBase: plugin.NewEntry(p.Name),
 		client:    client,
 		config:    config,
 		ns:        ns,
-		startTime: p.CreationTimestamp.Time,
 	}
+	pd.DisableDefaultCaching()
 
-	return pd
-}
-
-func (p *pod) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
-	pd, err := p.client.CoreV1().Pods(p.ns).Get(p.Name(), metav1.GetOptions{})
+	pdInfo := podInfoResult{
+		pd: p,
+	}
+	logContent, err := pd.fetchLogContent(ctx)
 	if err != nil {
 		return nil, err
 	}
+	pdInfo.logContent = logContent
+
+	meta := pdInfo.toMeta()
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(p.CreationTimestamp.Time).
+		SetAtime(attr.Ctime()).
+		SetSize(uint64(meta["LogSize"].(int))).
+		SetMeta(meta)
+	pd.SetInitialAttributes(attr)
+	pd.Sync(plugin.SizeAttr(), "LogSize")
+
+	return pd, nil
+}
 
-	journal.Record(ctx, "Metadata for pod %v: %+v", p.Name(), pd)
-	return plugin.ToMetadata(pd), nil
+type podInfoResult struct {
+	pd         *corev1.Pod
+	logContent []byte
 }
 
-func (p *pod) Attr(ctx context.Context) (plugin.Attributes, error) {
-	return plugin.Attributes{
-		Ctime: p.startTime,
-		Mtime: time.Now(),
-		Atime: p.startTime,
-		Size:  plugin.SizeUnknown,
-	}, nil
+func (pdInfo podInfoResult) toMeta() plugin.EntryMetadata {
+	meta := plugin.ToMeta(pdInfo.pd)
+	meta["LogSize"] = len(pdInfo.logContent)
+	return meta
 }
 
-func (p *pod) Open(ctx context.Context) (plugin.SizedReader, error) {
+func (p *pod) fetchLogContent(ctx context.Context) ([]byte, error) {
 	req := p.client.CoreV1().Pods(p.ns).GetLogs(p.Name(), &corev1.PodLogOptions{})
 	rdr, err := req.Stream()
 	if err != nil {
@@ -68,7 +78,47 @@ func (p *pod) Open(ctx context.Context) (plugin.SizedReader, error) {
 		return nil, err
 	}
 	journal.Record(ctx, "Read %v bytes of %v log", n, p.Name())
-	return bytes.NewReader(buf.Bytes()), nil
+	return buf.Bytes(), nil
+}
+
+func (p *pod) cachedPodInfo(ctx context.Context) (podInfoResult, error) {
+	cachedPdInfo, err := plugin.CachedOp("PodInfo", p, 15*time.Second, func() (interface{}, error) {
+		result := podInfoResult{}
+		pd, err := p.client.CoreV1().Pods(p.ns).Get(p.Name(), metav1.GetOptions{})
+		if err != nil {
+			return result, err
+		}
+		result.pd = pd
+		logContent, err := p.fetchLogContent(ctx)
+		if err != nil {
+			return result, err
+		}
+		result.logContent = logContent
+		return result, nil
+	})
+	if err != nil {
+		return podInfoResult{}, err
+	}
+
+	return cachedPdInfo.(podInfoResult), nil
+}
+
+func (p *pod) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
+	pdInfo, err := p.cachedPodInfo(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return pdInfo.toMeta(), nil
+}
+
+func (p *pod) Open(ctx context.Context) (plugin.SizedReader, error) {
+	pdInfo, err := p.cachedPodInfo(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return bytes.NewReader(pdInfo.logContent), nil
 }
 
 func (p *pod) Stream(ctx context.Context) (io.Reader, error) {
diff --git a/plugin/kubernetes/pods.go b/plugin/kubernetes/pods.go
index 5951cfc..dcde3a4 100644
--- a/plugin/kubernetes/pods.go
+++ b/plugin/kubernetes/pods.go
@@ -25,7 +25,12 @@ func (ps *pods) List(ctx context.Context) ([]plugin.Entry, error) {
 	}
 	entries := make([]plugin.Entry, len(podList.Items))
 	for i, p := range podList.Items {
-		entries[i] = newPod(ps.client, ps.config, ps.ns, &p)
+		pd, err := newPod(ctx, ps.client, ps.config, ps.ns, &p)
+		if err != nil {
+			return nil, err
+		}
+
+		entries[i] = pd
 	}
 	return entries, nil
 }
diff --git a/plugin/kubernetes/pvc.go b/plugin/kubernetes/pvc.go
index 840e843..7b39474 100644
--- a/plugin/kubernetes/pvc.go
+++ b/plugin/kubernetes/pvc.go
@@ -19,9 +19,8 @@ import (
 
 type pvc struct {
 	plugin.EntryBase
-	pvci      typedv1.PersistentVolumeClaimInterface
-	podi      typedv1.PodInterface
-	startTime time.Time
+	pvci typedv1.PersistentVolumeClaimInterface
+	podi typedv1.PodInterface
 }
 
 const mountpoint = "/mnt"
@@ -33,29 +32,27 @@ func newPVC(pi typedv1.PersistentVolumeClaimInterface, pd typedv1.PodInterface,
 		EntryBase: plugin.NewEntry(p.Name),
 		pvci:      pi,
 		podi:      pd,
-		startTime: p.CreationTimestamp.Time,
 	}
-	vol.SetTTLOf(plugin.List, 60*time.Second)
+	vol.SetTTLOf(plugin.ListOp, 60*time.Second)
+
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(p.CreationTimestamp.Time).
+		SetMtime(attr.Ctime()).
+		SetAtime(attr.Ctime())
+	vol.SetInitialAttributes(attr)
 
 	return vol
 }
 
-func (v *pvc) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
+func (v *pvc) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
 	obj, err := v.pvci.Get(v.Name(), metav1.GetOptions{})
 	if err != nil {
 		return nil, err
 	}
 	journal.Record(ctx, "Metadata for persistent volume claim %v: %+v", v.Name(), obj)
 
-	return plugin.ToMetadata(obj), nil
-}
-
-func (v *pvc) Attr(ctx context.Context) (plugin.Attributes, error) {
-	return plugin.Attributes{
-		Ctime: v.startTime,
-		Mtime: v.startTime,
-		Atime: v.startTime,
-	}, nil
+	return plugin.ToMeta(obj), nil
 }
 
 func (v *pvc) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -100,7 +97,7 @@ func (v *pvc) List(ctx context.Context) ([]plugin.Entry, error) {
 	root := dirs[""]
 	entries := make([]plugin.Entry, 0, len(root))
 	for name, attr := range root {
-		if attr.Mode.IsDir() {
+		if attr.Mode().IsDir() {
 			entries = append(entries, volume.NewDir(name, attr, v.getContentCB(), "/"+name, dirs))
 		} else {
 			entries = append(entries, volume.NewFile(name, attr, v.getContentCB(), "/"+name))
diff --git a/plugin/registry.go b/plugin/registry.go
index ca5e98a..e7432fb 100644
--- a/plugin/registry.go
+++ b/plugin/registry.go
@@ -17,7 +17,7 @@ type Registry struct {
 // NewRegistry creates a new plugin registry object
 func NewRegistry() *Registry {
 	r := &Registry{
-		EntryBase: newEntryBase("/"),
+		EntryBase: NewEntry("/"),
 		plugins:   make(map[string]Root),
 	}
 	r.setID("/")
@@ -41,17 +41,17 @@ func (r *Registry) RegisterPlugin(root Root) error {
 		return err
 	}
 
-	if !pluginNameRegex.MatchString(root.Name()) {
-		msg := fmt.Sprintf("r.RegisterPlugin: invalid plugin name %v. The plugin name must consist of alphanumeric characters, or a hyphen", root.Name())
+	if !pluginNameRegex.MatchString(root.name()) {
+		msg := fmt.Sprintf("r.RegisterPlugin: invalid plugin name %v. The plugin name must consist of alphanumeric characters, or a hyphen", root.name())
 		panic(msg)
 	}
 
-	if _, ok := r.plugins[root.Name()]; ok {
-		msg := fmt.Sprintf("r.RegisterPlugin: the %v plugin's already been registered", root.Name())
+	if _, ok := r.plugins[root.name()]; ok {
+		msg := fmt.Sprintf("r.RegisterPlugin: the %v plugin's already been registered", root.name())
 		panic(msg)
 	}
 
-	r.plugins[root.Name()] = root
+	r.plugins[root.name()] = root
 	r.pluginRoots = append(r.pluginRoots, root)
 	return nil
 }
diff --git a/plugin/syncableAttribute.go b/plugin/syncableAttribute.go
new file mode 100644
index 0000000..e8ce21c
--- /dev/null
+++ b/plugin/syncableAttribute.go
@@ -0,0 +1,170 @@
+package plugin
+
+import (
+	"fmt"
+	"time"
+)
+
+// AtimeAttr represents the Atime attribute in plugin.EntryAttributes
+func AtimeAttr() *SyncableAttribute {
+	return &atimeAttr
+}
+
+// MtimeAttr represents the Mtime attribute in plugin.EntryAttributes
+func MtimeAttr() *SyncableAttribute {
+	return &mtimeAttr
+}
+
+// CtimeAttr represents the Ctime attribute in plugin.EntryAttributes
+func CtimeAttr() *SyncableAttribute {
+	return &ctimeAttr
+}
+
+// ModeAttr represents the Mode attribute in plugin.EntryAttributes
+func ModeAttr() *SyncableAttribute {
+	return &modeAttr
+}
+
+// SizeAttr represents the Size attribute in plugin.EntryAttributes
+func SizeAttr() *SyncableAttribute {
+	return &sizeAttr
+}
+
+// SyncableAttribute represents a syncable attribute in
+// plugin.EntryAttributes. It abstracts away the common logic of
+// syncing the attribute with its corresponding key in the entry's
+// metadata whenever the latter's refreshed.
+type SyncableAttribute struct {
+	name   string
+	setter func(*EntryBase, interface{}) error
+}
+
+func (field SyncableAttribute) sync(entry *EntryBase, meta EntryMetadata, key string) error {
+	value, ok := meta[key]
+	if !ok {
+		return fmt.Errorf(
+			"the metadata does not contain a value in its %v key",
+			key,
+		)
+	}
+	if value == nil {
+		return fmt.Errorf(
+			"the metadata's %v key is set to null",
+			key,
+		)
+	}
+	if err := field.setter(entry, value); err != nil {
+		return fmt.Errorf(
+			"failed to munge the metadata's %v key: %v",
+			key,
+			err,
+		)
+	}
+
+	return nil
+}
+
+var atimeAttr = SyncableAttribute{
+	name: "atime",
+	setter: func(entry *EntryBase, v interface{}) error {
+		t, err := mungeToTimeVal(v)
+		if err != nil {
+			return err
+		}
+
+		entry.attr.SetAtime(t)
+		return nil
+	},
+}
+
+var mtimeAttr = SyncableAttribute{
+	name: "mtime",
+	setter: func(entry *EntryBase, v interface{}) error {
+		t, err := mungeToTimeVal(v)
+		if err != nil {
+			return err
+		}
+
+		entry.attr.SetMtime(t)
+		return nil
+	},
+}
+
+var ctimeAttr = SyncableAttribute{
+	name: "ctime",
+	setter: func(entry *EntryBase, v interface{}) error {
+		t, err := mungeToTimeVal(v)
+		if err != nil {
+			return err
+		}
+
+		entry.attr.SetCtime(t)
+		return nil
+	},
+}
+
+var modeAttr = SyncableAttribute{
+	name: "mode",
+	setter: func(entry *EntryBase, v interface{}) error {
+		m, err := ToFileMode(v)
+		if err != nil {
+			return err
+		}
+
+		entry.attr.SetMode(m)
+		return nil
+	},
+}
+
+var sizeAttr = SyncableAttribute{
+	name: "size",
+	setter: func(entry *EntryBase, v interface{}) error {
+		size, err := mungeToSizeVal(v)
+		if err != nil {
+			return err
+		}
+
+		entry.attr.SetSize(size)
+		return nil
+	},
+}
+
+func mungeToTimeVal(v interface{}) (time.Time, error) {
+	switch t := v.(type) {
+	case time.Time:
+		return t, nil
+	case string:
+		// TODO: The layout should be specified by the plugin author.
+		// We could likely have helpers that generate a munging function
+		// given some parameters. For now, this is enough.
+		layout := "2006-01-02T15:04:05Z"
+		return time.Parse(layout, t)
+	default:
+		return time.Time{}, fmt.Errorf("%v is not a time.Time value", v)
+	}
+}
+
+func mungeIntSize(size int64) (uint64, error) {
+	if size < 0 {
+		return 0, fmt.Errorf("%v is a negative size", size)
+	}
+
+	return uint64(size), nil
+}
+
+func mungeToSizeVal(v interface{}) (uint64, error) {
+	switch sz := v.(type) {
+	case uint64:
+		return sz, nil
+	case int:
+		return mungeIntSize(int64(sz))
+	case int32:
+		return mungeIntSize(int64(sz))
+	case int64:
+		return mungeIntSize(sz)
+	case float64:
+		return mungeIntSize(int64(sz))
+	default:
+		return 0, fmt.Errorf("%v is not a valid size type. Valid size types are uint64, int, int64", v)
+	}
+}
diff --git a/plugin/types.go b/plugin/types.go
index d5b3f2d..1ca6d3f 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -28,7 +28,6 @@ package plugin
 import (
 	"context"
 	"io"
-	"os"
 	"time"
 )
 
@@ -36,21 +35,15 @@ import (
 // interface, meaning you must use plugin.NewEntry when
 // creating your plugin objects.
 type Entry interface {
-	Name() string
-	Attr(ctx context.Context) (Attributes, error)
+	Metadata(ctx context.Context) (EntryMetadata, error)
+	name() string
+	attributes() EntryAttributes
+	hasSyncedAttributes() bool
+	syncAttributesWith(meta EntryMetadata) error
 	slashReplacementChar() rune
 	id() string
 	setID(id string)
-	getTTLOf(op actionOpCode) time.Duration
-}
-
-// MetadataMap maps keys to arbitrary structured data.
-type MetadataMap = map[string]interface{}
-
-// Resource is an entry that has metadata.
-type Resource interface {
-	Entry
-	Metadata(context.Context) (MetadataMap, error)
+	getTTLOf(op defaultOpCode) time.Duration
 }
 
 // Group is an entry that can list its contents, an array of entries.
@@ -116,16 +109,3 @@ type Writable interface {
 	Entry
 	Save(context.Context, io.Reader) error
 }
-
-// Attributes of resources.
-type Attributes struct {
-	Atime time.Time     `json:"atime"`
-	Mtime time.Time     `json:"mtime"`
-	Ctime time.Time     `json:"ctime"`
-	Mode  os.FileMode   `json:"mode"`
-	Size  uint64        `json:"size"`
-	Valid time.Duration `json:"valid"`
-}
-
-// SizeUnknown can be used to denote that the size is unknown and should be queried from content.
-const SizeUnknown = ^uint64(0)
diff --git a/volume/dir.go b/volume/dir.go
index c7ea7a9..fc7dd36 100644
--- a/volume/dir.go
+++ b/volume/dir.go
@@ -16,37 +16,31 @@ import (
 // when constructing an entire hierarchy.
 type Dir struct {
 	plugin.EntryBase
-	attr      plugin.Attributes
 	contentcb ContentCB
 	path      string
 	dirs      DirMap
 }
 
 // NewDir creates a Dir populated from dirs.
-func NewDir(name string, attr plugin.Attributes, cb ContentCB, path string, dirs DirMap) *Dir {
+func NewDir(name string, attr plugin.EntryAttributes, cb ContentCB, path string, dirs DirMap) *Dir {
 	vd := &Dir{
 		EntryBase: plugin.NewEntry(name),
-		attr:      attr,
 		contentcb: cb,
 		path:      path,
 		dirs:      dirs,
 	}
-	vd.SetTTLOf(plugin.Open, 60*time.Second)
+	vd.SetInitialAttributes(attr)
+	vd.SetTTLOf(plugin.OpenOp, 60*time.Second)
 
 	return vd
 }
 
-// Attr returns the attributes of the directory.
-func (v *Dir) Attr(ctx context.Context) (plugin.Attributes, error) {
-	return v.attr, nil
-}
-
 // List lists the children of the directory.
 func (v *Dir) List(ctx context.Context) ([]plugin.Entry, error) {
 	root := v.dirs[v.path]
 	entries := make([]plugin.Entry, 0, len(root))
 	for name, attr := range root {
-		if attr.Mode.IsDir() {
+		if attr.Mode().IsDir() {
 			entries = append(entries, NewDir(name, attr, v.contentcb, v.path+"/"+name, v.dirs))
 		} else {
 			entries = append(entries, NewFile(name, attr, v.contentcb, v.path+"/"+name))
diff --git a/volume/file.go b/volume/file.go
index 7fa255b..7b6c9ed 100644
--- a/volume/file.go
+++ b/volume/file.go
@@ -13,29 +13,23 @@ type ContentCB = func(context.Context, string) (plugin.SizedReader, error)
 // File represents a file in a volume that has content we can access.
 type File struct {
 	plugin.EntryBase
-	attr      plugin.Attributes
 	contentcb ContentCB
 	path      string
 }
 
 // NewFile creates a VolumeFile.
-func NewFile(name string, attr plugin.Attributes, cb ContentCB, path string) *File {
+func NewFile(name string, attr plugin.EntryAttributes, cb ContentCB, path string) *File {
 	vf := &File{
 		EntryBase: plugin.NewEntry(name),
-		attr:      attr,
 		contentcb: cb,
 		path:      path,
 	}
-	vf.SetTTLOf(plugin.Open, 60*time.Second)
+	vf.SetInitialAttributes(attr)
+	vf.SetTTLOf(plugin.OpenOp, 60*time.Second)
 
 	return vf
 }
 
-// Attr returns the attributes of the file.
-func (v *File) Attr(ctx context.Context) (plugin.Attributes, error) {
-	return v.attr, nil
-}
-
 // Open returns the content of the file as a SizedReader.
 func (v *File) Open(ctx context.Context) (plugin.SizedReader, error) {
 	return v.contentcb(ctx, v.path)
diff --git a/volume/helpers.go b/volume/helpers.go
index 0d94abc..6fa44b9 100644
--- a/volume/helpers.go
+++ b/volume/helpers.go
@@ -30,47 +30,67 @@ func StatCmd(path string) []string {
 	return []string{"find", path, "-mindepth", "1", "-exec", "stat", "-c", "%s %X %Y %Z %f %n", "{}", ";"}
 }
 
-// StatParse parses a single line of the output of StatCmd into Attributes and a name.
-func StatParse(line string) (plugin.Attributes, string, error) {
-	var attr plugin.Attributes
+// TODO: Should move this over to plugin as a helper at some point
+func parseTime(t string) (time.Time, error) {
+	epoch, err := strconv.ParseInt(t, 10, 64)
+	if err != nil {
+		return time.Time{}, err
+	}
+	return time.Unix(epoch, 0), nil
+}
+
+// StatParse parses a single line of the output of StatCmd into EntryAttributes and a name.
+func StatParse(line string) (plugin.EntryAttributes, string, error) {
+	var attr plugin.EntryAttributes
 	segments := strings.SplitN(line, " ", 6)
 	if len(segments) != 6 {
 		return attr, "", fmt.Errorf("Stat did not return 6 components: %v", line)
 	}
 
-	var err error
-	attr.Size, err = strconv.ParseUint(segments[0], 10, 64)
+	size, err := strconv.ParseUint(segments[0], 10, 64)
 	if err != nil {
 		return attr, "", err
 	}
+	attr.SetSize(size)
 
-	for i, target := range []*time.Time{&attr.Atime, &attr.Mtime, &attr.Ctime} {
-		epoch, err := strconv.ParseInt(segments[i+1], 10, 64)
-		if err != nil {
-			return attr, "", err
-		}
-		*target = time.Unix(epoch, 0)
+	atime, err := parseTime(segments[1])
+	if err != nil {
+		return attr, "", err
+	}
+	attr.SetAtime(atime)
+
+	mtime, err := parseTime(segments[2])
+	if err != nil {
+		return attr, "", err
+	}
+	attr.SetMtime(mtime)
+
+	ctime, err := parseTime(segments[3])
+	if err != nil {
+		return attr, "", err
 	}
+	attr.SetCtime(ctime)
 
 	mode, err := plugin.ToFileMode("0x" + segments[4])
 	if err != nil {
 		return attr, "", err
 	}
-	attr.Mode = mode
+	attr.SetMode(mode)
 
 	return attr, segments[5], nil
 }
 
 // A DirMap is a map of directory names to a map of their children and the children's attributes.
-type DirMap = map[string]map[string]plugin.Attributes
+type DirMap = map[string]map[string]plugin.EntryAttributes
 
 // StatParseAll an output stream that is the result of running StatCmd. Strips 'base' from the
-// file paths, and maps each directory to a map of files in that directory and their attributes.
+// file paths, and maps each directory to a map of files in that directory and their attr
+// (attributes).
 func StatParseAll(output io.Reader, base string) (DirMap, error) {
 	scanner := bufio.NewScanner(output)
 	// Create lookup table for directories to contents, and prepopulate the root entry because
 	// the mount point won't be included in the stat output.
-	attrs := DirMap{"": make(map[string]plugin.Attributes)}
+	attrs := DirMap{"": make(map[string]plugin.EntryAttributes)}
 	for scanner.Scan() {
 		text := strings.TrimSpace(scanner.Text())
 		if text != "" {
@@ -81,8 +101,8 @@ func StatParseAll(output io.Reader, base string) (DirMap, error) {
 
 			relative := strings.TrimPrefix(fullpath, base)
 			// Create an entry for each directory.
-			if attr.Mode.IsDir() {
-				attrs[relative] = make(map[string]plugin.Attributes)
+			if attr.Mode().IsDir() {
+				attrs[relative] = make(map[string]plugin.EntryAttributes)
 			}
 
 			// Add each entry to its parent's listing.


Test patch:
diff --git a/api/helpers_test.go b/api/helpers_test.go
index 600e084..908c260 100644
--- a/api/helpers_test.go
+++ b/api/helpers_test.go
@@ -2,8 +2,11 @@ package api
 
 import (
 	"context"
+	"os"
 	"testing"
+	"time"
 
+	apitypes "github.com/puppetlabs/wash/api/types"
 	"github.com/puppetlabs/wash/plugin"
 	"github.com/stretchr/testify/suite"
 )
@@ -29,6 +32,74 @@ func (suite *HelpersTestSuite) TearDownSuite() {
 	plugin.UnsetTestCache()
 }
 
+func (suite *HelpersTestSuite) TestToAPIAttrAtime() {
+	a := plugin.EntryAttributes{}
+	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
+
+	a.SetAtime(time.Now())
+	apiAttr := toAPIAttr(a)
+	if suite.NotNil(apiAttr.Atime) {
+		suite.Equal(a.Atime(), *apiAttr.Atime)
+	}
+}
+
+func (suite *HelpersTestSuite) TestToAPIAttrMtime() {
+	a := plugin.EntryAttributes{}
+	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
+
+	a.SetMtime(time.Now())
+	apiAttr := toAPIAttr(a)
+	if suite.NotNil(apiAttr.Mtime) {
+		suite.Equal(a.Mtime(), *apiAttr.Mtime)
+	}
+}
+
+func (suite *HelpersTestSuite) TestToAPIAttrCtime() {
+	a := plugin.EntryAttributes{}
+	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
+
+	a.SetCtime(time.Now())
+	apiAttr := toAPIAttr(a)
+	if suite.NotNil(apiAttr.Ctime) {
+		suite.Equal(a.Ctime(), *apiAttr.Ctime)
+	}
+}
+
+func (suite *HelpersTestSuite) TestToAPIAttrMode() {
+	a := plugin.EntryAttributes{}
+	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
+
+	a.SetMode(0777)
+	apiAttr := toAPIAttr(a)
+	if suite.NotNil(apiAttr.Mode) {
+		suite.Equal(a.Mode(), *apiAttr.Mode)
+	}
+}
+
+func (suite *HelpersTestSuite) TestToAPIAttrSize() {
+	a := plugin.EntryAttributes{}
+	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
+
+	a.SetSize(10)
+	apiAttr := toAPIAttr(a)
+	if suite.NotNil(apiAttr.Size) {
+		suite.Equal(a.Size(), *apiAttr.Size)
+	}
+}
+
+func (suite *HelpersTestSuite) TestToAPIAttrMultipleAttr() {
+	a := plugin.EntryAttributes{}
+	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
+
+	a.SetMode(os.FileMode(0777))
+	a.SetSize(10)
+	apiAttr := toAPIAttr(a)
+	if suite.NotNil(apiAttr.Mode) && suite.NotNil(apiAttr.Size) {
+		suite.Equal(a.Mode(), *apiAttr.Mode)
+		suite.Equal(a.Size(), *apiAttr.Size)
+	}
+}
+
 func (suite *HelpersTestSuite) TestFindEntry() {
 	type testcase struct {
 		segments      []string
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index 2e3cb17..f5e1f4b 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -123,7 +123,7 @@ type cacheTestsMockEntry struct {
 
 func newCacheTestsMockEntry(name string) *cacheTestsMockEntry {
 	return &cacheTestsMockEntry{
-		EntryBase: newEntryBase(name),
+		EntryBase: NewEntry(name),
 	}
 }
 
@@ -137,12 +137,12 @@ func (e *cacheTestsMockEntry) Open(ctx context.Context) (SizedReader, error) {
 	return args.Get(0).(SizedReader), args.Error(1)
 }
 
-func (e *cacheTestsMockEntry) Metadata(ctx context.Context) (MetadataMap, error) {
+func (e *cacheTestsMockEntry) Metadata(ctx context.Context) (EntryMetadata, error) {
 	args := e.Called(ctx)
-	return args.Get(0).(MetadataMap), args.Error(1)
+	return args.Get(0).(EntryMetadata), args.Error(1)
 }
 
-type cachedActionOpFunc func(ctx context.Context, e Entry) (interface{}, error)
+type cachedDefaultOpFunc func(ctx context.Context, e Entry) (interface{}, error)
 
 func (suite *CacheTestSuite) TestCachedOp() {
 	makePanicFunc := func(opName string, ttl time.Duration) func() {
@@ -200,34 +200,42 @@ func (suite *CacheTestSuite) TestDuplicateCNameErr() {
 	suite.Regexp("my_plugin plugin", err.Error())
 }
 
-func (suite *CacheTestSuite) testCachedActionOp(op actionOpCode, opName string, mockValue interface{}, cachedActionOp cachedActionOpFunc) {
+func (suite *CacheTestSuite) testCachedDefaultOp(op defaultOpCode, opName string, mockValue interface{}, cachedDefaultOp cachedDefaultOpFunc) {
 	ctx := context.Background()
 
-	// Test that cachedActionOp panics if the cache == nil
+	// Test that cachedDefaultOp panics if the cache == nil
 	panicFunc := func() {
 		entry := newCacheTestsMockEntry("mock")
-		_, _ = cachedActionOp(ctx, entry)
+		_, _ = cachedDefaultOp(ctx, entry)
 	}
 	UnsetTestCache()
 	suite.Panics(panicFunc)
 	SetTestCache(suite.cache)
 
-	// Test that cachedActionOp panics if entry.id() == ""
+	entry := newCacheTestsMockEntry("mock")
+
+	// Test that cachedDefaultOp does _not_ call cache#GetOrUpdate if
+	// entry.id() == "" (possible if the entry's just been created and
+	// it needs to query its metadata to set its initial attributes).
 	suite.Panics(panicFunc, "entry.id() returned an empty ID")
+	entry.On(opName, ctx).Return(mockValue, nil)
+	v, err := cachedDefaultOp(ctx, entry)
+	if suite.NoError(err) {
+		suite.Equal(mockValue, v)
+	}
 
-	// Test that cachedActionOp does _not_ call cache#GetOrUpdate for an
+	// Test that cachedDefaultOp does _not_ call cache#GetOrUpdate for an
 	// entry that's turned off caching
-	entry := newCacheTestsMockEntry("mock")
 	entry.SetTestID("id")
 	entry.On(opName, ctx).Return(mockValue, nil)
 	entry.DisableCachingFor(op)
-	v, err := cachedActionOp(ctx, entry)
+	v, err = cachedDefaultOp(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(mockValue, v)
 	}
 	suite.cache.AssertNotCalled(suite.T(), "GetOrUpdate")
 
-	// Test that cachedActionOp does call cache#GetOrUpdate for an
+	// Test that cachedDefaultOp does call cache#GetOrUpdate for an
 	// entry that's enabled caching, and that it passes-in the
 	// right arguments.
 	opTTL := 5 * time.Second
@@ -236,16 +244,16 @@ func (suite *CacheTestSuite) testCachedActionOp(op actionOpCode, opName string,
 	opKey := opName + "::" + "id"
 	generateValueMatcher := suite.makeGenerateValueMatcher(mockValue)
 	suite.cache.On("GetOrUpdate", opKey, opTTL, false, mock.MatchedBy(generateValueMatcher)).Return(mockValue, nil).Once()
-	v, err = cachedActionOp(ctx, entry)
+	v, err = cachedDefaultOp(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(mockValue, v)
 	}
 	suite.cache.AssertCalled(suite.T(), "GetOrUpdate", opKey, opTTL, false, mock.MatchedBy(generateValueMatcher))
 }
 
-func (suite *CacheTestSuite) TestCachedListActionOp() {
+func (suite *CacheTestSuite) TestCachedListDefaultOp() {
 	mockChildren := []Entry{newCacheTestsMockEntry("mockChild")}
-	suite.testCachedActionOp(List, "List", mockChildren, func(ctx context.Context, e Entry) (interface{}, error) {
+	suite.testCachedDefaultOp(ListOp, "List", mockChildren, func(ctx context.Context, e Entry) (interface{}, error) {
 		return CachedList(ctx, e.(Group))
 	})
 }
@@ -315,15 +323,15 @@ func (suite *CacheTestSuite) TestCachedListSetEntryID() {
 
 func (suite *CacheTestSuite) TestCachedOpen() {
 	mockReader := strings.NewReader("foo")
-	suite.testCachedActionOp(Open, "Open", mockReader, func(ctx context.Context, e Entry) (interface{}, error) {
+	suite.testCachedDefaultOp(OpenOp, "Open", mockReader, func(ctx context.Context, e Entry) (interface{}, error) {
 		return CachedOpen(ctx, e.(Readable))
 	})
 }
 
 func (suite *CacheTestSuite) TestCachedMetadata() {
-	mockMetadataMap := MetadataMap{"foo": "bar"}
-	suite.testCachedActionOp(Metadata, "Metadata", mockMetadataMap, func(ctx context.Context, e Entry) (interface{}, error) {
-		return CachedMetadata(ctx, e.(Resource))
+	mockEntryMetadata := EntryMetadata{"foo": "bar"}
+	suite.testCachedDefaultOp(MetadataOp, "Metadata", mockEntryMetadata, func(ctx context.Context, e Entry) (interface{}, error) {
+		return CachedMetadata(ctx, e)
 	})
 }
 
diff --git a/plugin/entryAttributes_test.go b/plugin/entryAttributes_test.go
new file mode 100644
index 0000000..03a3372
--- /dev/null
+++ b/plugin/entryAttributes_test.go
@@ -0,0 +1,75 @@
+package plugin
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/suite"
+)
+
+type EntryAttributesTestSuite struct {
+	suite.Suite
+}
+
+func (suite *EntryAttributesTestSuite) TestToMeta() {
+	cases := []struct {
+		input    interface{}
+		expected EntryMetadata
+	}{
+		{[]byte(`{"hello": [1, 2, 3]}`), EntryMetadata{"hello": []interface{}{1.0, 2.0, 3.0}}},
+		{struct {
+			Name  string
+			Value []int
+		}{"me", []int{1, 2, 3}}, EntryMetadata{"Name": "me", "Value": []interface{}{1.0, 2.0, 3.0}}},
+	}
+	for _, c := range cases {
+		actual := ToMeta(c.input)
+		suite.Equal(c.expected, actual)
+	}
+}
+
+// Easier to have a single test for everything since they're
+// all redundant
+func (suite *EntryAttributesTestSuite) TestEntryAttributes() {
+	attr := EntryAttributes{}
+
+	suite.Equal(false, attr.HasAtime())
+	t := time.Now()
+	attr.SetAtime(t)
+	suite.Equal(t, attr.Atime())
+	suite.Equal(true, attr.HasAtime())
+
+	suite.Equal(false, attr.HasMtime())
+	t = time.Now()
+	attr.SetMtime(t)
+	suite.Equal(t, attr.Mtime())
+	suite.Equal(true, attr.HasMtime())
+
+	suite.Equal(false, attr.HasCtime())
+	t = time.Now()
+	attr.SetCtime(t)
+	suite.Equal(t, attr.Ctime())
+	suite.Equal(true, attr.HasCtime())
+
+	suite.Equal(false, attr.HasMode())
+	m := os.FileMode(0777)
+	attr.SetMode(m)
+	suite.Equal(m, attr.Mode())
+	suite.Equal(true, attr.HasMode())
+
+	suite.Equal(false, attr.HasSize())
+	sz := uint64(10)
+	attr.SetSize(sz)
+	suite.Equal(sz, attr.Size())
+	suite.Equal(true, attr.HasSize())
+
+	suite.Equal(EntryMetadata{}, attr.Meta())
+	meta := EntryMetadata{"foo": "bar"}
+	attr.SetMeta(meta)
+	suite.Equal(meta, attr.Meta())
+}
+
+func TestEntryAttributes(t *testing.T) {
+	suite.Run(t, new(EntryAttributesTestSuite))
+}
diff --git a/plugin/entryBase_test.go b/plugin/entryBase_test.go
index 3856f6d..6dab3e0 100644
--- a/plugin/entryBase_test.go
+++ b/plugin/entryBase_test.go
@@ -2,6 +2,7 @@ package plugin
 
 import (
 	"context"
+	"os"
 	"testing"
 	"time"
 
@@ -12,51 +13,68 @@ type EntryBaseTestSuite struct {
 	suite.Suite
 }
 
+func (suite *EntryBaseTestSuite) assertOpTTL(e EntryBase, op defaultOpCode, opName string, expectedTTL time.Duration) {
+	actualTTL := e.getTTLOf(op)
+	suite.Equal(
+		expectedTTL,
+		actualTTL,
+		"expected the TTL of %v to be %v, but got %v instead",
+		opName,
+		expectedTTL,
+		actualTTL,
+	)
+}
+
 func (suite *EntryBaseTestSuite) TestNewEntry() {
 	suite.Panics(
 		func() { NewEntry("") },
 		"plugin.NewEntry: received an empty name",
 	)
 
+	initialAttr := EntryAttributes{}
+	initialAttr.SetCtime(time.Now())
 	e := NewEntry("foo")
-	assertOpTTL := func(op actionOpCode, opName string, expectedTTL time.Duration) {
-		actualTTL := e.getTTLOf(op)
-		suite.Equal(
-			expectedTTL,
-			actualTTL,
-			"expected the TTL of %v to be %v, but got %v instead",
-			opName,
-			expectedTTL,
-			actualTTL,
-		)
-	}
+
+	e.SetInitialAttributes(initialAttr)
+	suite.Equal(initialAttr, e.attr)
 
 	suite.Equal("foo", e.Name())
-	assertOpTTL(List, "List", 15*time.Second)
-	assertOpTTL(Open, "Open", 15*time.Second)
-	assertOpTTL(Metadata, "Metadata", 15*time.Second)
+	suite.assertOpTTL(e, ListOp, "List", 15*time.Second)
+	suite.assertOpTTL(e, OpenOp, "Open", 15*time.Second)
+	suite.assertOpTTL(e, MetadataOp, "Metadata", 15*time.Second)
 
 	e.setID("/foo")
 	suite.Equal("/foo", e.id())
 
-	e.SetTTLOf(List, 40*time.Second)
-	assertOpTTL(List, "List", 40*time.Second)
+	e.SetTTLOf(ListOp, 40*time.Second)
+	suite.assertOpTTL(e, ListOp, "List", 40*time.Second)
 
-	e.DisableCachingFor(List)
-	assertOpTTL(List, "List", -1)
+	e.DisableCachingFor(ListOp)
+	suite.assertOpTTL(e, ListOp, "List", -1)
 
 	e.DisableDefaultCaching()
-	assertOpTTL(Open, "Open", -1)
-	assertOpTTL(Metadata, "Metadata", -1)
+	suite.assertOpTTL(e, OpenOp, "Open", -1)
+	suite.assertOpTTL(e, MetadataOp, "Metadata", -1)
+}
+
+func (suite *EntryBaseTestSuite) TestMetadata() {
+	e := NewEntry("foo")
 
-	ctx := context.Background()
-	attr, err := e.Attr(ctx)
+	meta, err := e.Metadata(context.Background())
 	if suite.NoError(err) {
-		expectedAttr := Attributes{
-			Size: SizeUnknown,
-		}
+		suite.Equal(EntryMetadata{}, meta)
+	}
+	suite.assertOpTTL(e, MetadataOp, "Metadata", -1)
+}
+
+func (suite *EntryBaseTestSuite) TestSync() {
+	e := NewEntry("foo")
 
-		suite.Equal(expectedAttr, attr)
+	e.Sync(CtimeAttr(), "CreationDate")
+	if suite.Equal(1, len(e.syncedAttrs)) {
+		syncedAttr := e.syncedAttrs[0]
+		suite.Equal(CtimeAttr().name, syncedAttr.name)
+		suite.Equal("CreationDate", syncedAttr.key)
 	}
 }
 
@@ -72,6 +90,57 @@ func (suite *EntryBaseTestSuite) TestSetSlashReplacementChar() {
 	suite.Equal(e.slashReplacementChar(), ':')
 }
 
+func (suite *EntryBaseTestSuite) TestHasSyncedAttributes() {
+	e := NewEntry("foo")
+	suite.Equal(false, e.hasSyncedAttributes())
+	e.Sync(CtimeAttr(), "CtimeKey")
+	suite.Equal(true, e.hasSyncedAttributes())
+}
+
+func (suite *EntryBaseTestSuite) TestSyncAttributesWithNoErrors() {
+	initialAttr := EntryAttributes{}
+	initialAttr.
+		SetCtime(time.Now()).
+		SetMtime(time.Now())
+
+	e := NewEntry("foo")
+	e.SetInitialAttributes(initialAttr)
+	e.Sync(MtimeAttr(), "LastModified")
+	e.Sync(SizeAttr(), "Size")
+
+	meta := EntryMetadata{
+		"LastModified": time.Now(),
+		"Size":         uint64(15),
+	}
+	err := e.syncAttributesWith(meta)
+	if suite.NoError(err) {
+		expectedAttr := initialAttr.toMap()
+		expectedAttr[MtimeAttr().name] = meta["LastModified"]
+		expectedAttr[SizeAttr().name] = meta["Size"]
+		expectedAttr["meta"] = meta
+
+		suite.Equal(expectedAttr, e.attr.toMap())
+	}
+}
+
+func (suite *EntryBaseTestSuite) TestSyncAttributesWithErrors() {
+	initialAttr := EntryAttributes{}
+	initialAttr.SetMode(os.FileMode(0))
+	initialAttr.SetSize(10)
+
+	e := NewEntry("foo")
+	e.SetInitialAttributes(initialAttr)
+	e.Sync(ModeAttr(), "Mode")
+	e.Sync(SizeAttr(), "Size")
+
+	meta := EntryMetadata{
+		"Mode": "badmode",
+		"Size": int64(-1),
+	}
+	err := e.syncAttributesWith(meta)
+	suite.Regexp("sync.*mode.*attr.*sync.*size.*attr", err)
+}
+
 func TestEntryBase(t *testing.T) {
 	suite.Run(t, new(EntryBaseTestSuite))
 }
diff --git a/plugin/externalPluginEntry_test.go b/plugin/externalPluginEntry_test.go
index ad0c1dd..5200c47 100644
--- a/plugin/externalPluginEntry_test.go
+++ b/plugin/externalPluginEntry_test.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"context"
 	"fmt"
-	"os"
 	"regexp"
 	"testing"
 	"time"
@@ -49,6 +48,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnixSecondsToTimeAttr() {
 	suite.EqualTimeAttr(t, unixSecondsToTimeAttr(t.Unix()))
 }
 
+/*
 func (suite *ExternalPluginEntryTestSuite) TestDecodeAttributes() {
 	atime := time.Now()
 	mtime := time.Now()
@@ -89,6 +89,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeAttributes() {
 	attributes, err = decodedAttributes.toAttributes()
 	suite.Error(err)
 }
+*/
 
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryRequiredFields() {
 	decodedEntry := decodedExternalPluginEntry{}
@@ -103,7 +104,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryRequired
 
 	entry, err := decodedEntry.toExternalPluginEntry()
 	if suite.NoError(err) {
-		suite.Equal(decodedEntry.Name, entry.name)
+		suite.Equal(decodedEntry.Name, entry.name())
 		suite.Equal(decodedEntry.SupportedActions, entry.supportedActions)
 	}
 }
@@ -130,7 +131,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithCach
 	entry, err := decodedEntry.toExternalPluginEntry()
 	if suite.NoError(err) {
 		expectedTTLs := NewEntry("mock").ttl
-		expectedTTLs[List] = decodedEntry.CacheTTLs.List * time.Second
+		expectedTTLs[ListOp] = decodedEntry.CacheTTLs.List * time.Second
 		suite.Equal(expectedTTLs, entry.EntryBase.ttl)
 	}
 }
@@ -149,6 +150,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSlas
 	}
 }
 
+/*
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithAttributes() {
 	decodedEntry := newMockDecodedEntry("name")
 	decodedEntry.Attributes = decodedAttributes{Size: 10}
@@ -161,6 +163,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithAttr
 	_, err = decodedEntry.toExternalPluginEntry()
 	suite.Error(err)
 }
+*/
 
 func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 	decodedTTLs := decodedCacheTTLs{
@@ -174,9 +177,9 @@ func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 	}
 	entry.setCacheTTLs(decodedTTLs)
 
-	suite.Equal(decodedTTLs.List*time.Second, entry.getTTLOf(List))
-	suite.Equal(decodedTTLs.Open*time.Second, entry.getTTLOf(Open))
-	suite.Equal(decodedTTLs.Metadata*time.Second, entry.getTTLOf(Metadata))
+	suite.Equal(decodedTTLs.List*time.Second, entry.getTTLOf(ListOp))
+	suite.Equal(decodedTTLs.Open*time.Second, entry.getTTLOf(OpenOp))
+	suite.Equal(decodedTTLs.Metadata*time.Second, entry.getTTLOf(MetadataOp))
 }
 
 // TODO: There's a bit of duplication between TestList, TestOpen,
@@ -288,17 +291,11 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata() {
 	mockInvokeAndWait([]byte(stdout), nil)
 	metadata, err := entry.Metadata(ctx)
 	if suite.NoError(err) {
-		expectedMetadata := MetadataMap{"key": "value"}
+		expectedMetadata := EntryMetadata{"key": "value"}
 		suite.Equal(expectedMetadata, metadata)
 	}
 }
 
-func (suite *ExternalPluginEntryTestSuite) TestAttr() {
-	entry := externalPluginEntry{attr: Attributes{Size: 10}}
-	attr, _ := entry.Attr(context.Background())
-	suite.Equal(entry.attr, attr)
-}
-
 // TODO: Add tests for stdoutStreamer, Stream and Exec
 // once the API for Stream and Exec's at a more stable
 // state.
diff --git a/plugin/helpers_test.go b/plugin/helpers_test.go
index 3e7a2d0..04cdf88 100644
--- a/plugin/helpers_test.go
+++ b/plugin/helpers_test.go
@@ -1,12 +1,10 @@
 package plugin
 
 import (
-	"context"
 	"fmt"
 	"os"
 	"os/exec"
 	"regexp"
-	"strings"
 	"testing"
 	"time"
 
@@ -27,6 +25,11 @@ func (suite *HelpersTestSuite) TearDownSuite() {
 	UnsetTestCache()
 }
 
+func (suite *HelpersTestSuite) TestName() {
+	e := NewEntry("foo")
+	suite.Equal(Name(&e), "foo")
+}
+
 func (suite *HelpersTestSuite) TestCName() {
 	e := NewEntry("foo/bar/baz")
 	suite.Equal("foo#bar#baz", CName(&e))
@@ -42,23 +45,6 @@ func (suite *HelpersTestSuite) TestPath() {
 	suite.Equal(Path(&e), "/foo/bar")
 }
 
-func (suite *HelpersTestSuite) TestToMetadata() {
-	cases := []struct {
-		input    interface{}
-		expected MetadataMap
-	}{
-		{[]byte(`{"hello": [1, 2, 3]}`), MetadataMap{"hello": []interface{}{1.0, 2.0, 3.0}}},
-		{struct {
-			Name  string
-			Value []int
-		}{"me", []int{1, 2, 3}}, MetadataMap{"Name": "me", "Value": []interface{}{1.0, 2.0, 3.0}}},
-	}
-	for _, c := range cases {
-		actual := ToMetadata(c.input)
-		suite.Equal(c.expected, actual)
-	}
-}
-
 func (suite *HelpersTestSuite) TestParseMode() {
 	type testCase struct {
 		input    interface{}
@@ -134,154 +120,11 @@ func newHelpersTestsMockEntry() *helpersTestsMockEntry {
 	return e
 }
 
-func (e *helpersTestsMockEntry) Attr(ctx context.Context) (Attributes, error) {
-	args := e.Called(ctx)
-	return args.Get(0).(Attributes), args.Error(1)
-}
-
-type helpersTestsMockGroup struct {
-	EntryBase
-	mock.Mock
-}
-
-func newHelpersTestsMockGroup() *helpersTestsMockGroup {
-	e := &helpersTestsMockGroup{
-		EntryBase: NewEntry("mockEntry"),
-	}
-	e.SetTestID("id")
-	e.DisableDefaultCaching()
-
-	return e
-}
-
-func (e *helpersTestsMockGroup) List(ctx context.Context) ([]Entry, error) {
-	args := e.Called()
-	return args.Get(0).([]Entry), args.Error(1)
-}
-
-func (suite *HelpersTestSuite) TestAttrAnyEntry() {
-	ctx := context.Background()
-
-	// Test that Attr()'s result is used if the entry implements it
-	// and if entry.Attr() returns a non-zero set of attributes
-	entry := newHelpersTestsMockEntry()
-	expectedAttributes := Attributes{
-		Ctime: time.Now(),
-		Size:  10,
-		Mode:  0777,
-	}
-	entry.On("Attr", ctx).Return(expectedAttributes, nil)
-	attr, err := Attr(context.Background(), entry)
-	if suite.NoError(err) {
-		suite.Equal(
-			expectedAttributes,
-			attr,
-			"Attr should use the entry's filesystem attributes if they're provided",
-		)
-	}
-
-	// Test that if the entry supports the list action, then it sets
-	// the mode to 0550
-	group := newHelpersTestsMockGroup()
-	attr, err = Attr(ctx, group)
-	if suite.NoError(err) {
-		suite.Equal(
-			Attributes{Mode: os.ModeDir | 0550, Size: SizeUnknown},
-			attr,
-			"Attr does not set the default mode to os.ModeDir | 0550 for entries that support the list action",
-		)
-	}
-
-	// Test that if the entry does not support the list action, then it sets
-	// the mode to 0440
-	entry = newHelpersTestsMockEntry()
-	entry.On("Attr", ctx).Return(Attributes{}, nil)
-	attr, err = Attr(context.Background(), entry)
-	if suite.NoError(err) {
-		suite.Equal(
-			Attributes{Mode: 0440},
-			attr,
-			"Attr does not set the default mode to 0440 for entries that do notsupport the list action",
-		)
-	}
-}
-
-type helpersTestsMockReadableEntry struct {
-	*helpersTestsMockEntry
-}
-
-func newHelpersTestsMockReadableEntry(ctx context.Context, attr Attributes) *helpersTestsMockReadableEntry {
-	e := &helpersTestsMockReadableEntry{
-		newHelpersTestsMockEntry(),
-	}
-
-	e.On("Attr", ctx).Return(attr, nil)
-	return e
-}
-
-func (e *helpersTestsMockReadableEntry) Open(ctx context.Context) (SizedReader, error) {
-	args := e.Called(ctx)
-	return args.Get(0).(SizedReader), args.Error(1)
-}
-
-type negativeSizedReader struct{}
-
-func (r negativeSizedReader) ReadAt(b []byte, off int64) (int, error) {
-	return 0, fmt.Errorf("negativeSizedReader is not meant to read anything")
-}
-
-func (r negativeSizedReader) Size() int64 {
-	return -1
-}
-
-func (suite *HelpersTestSuite) TestAttrReadableEntry() {
-	ctx := context.Background()
-	mockRdr := strings.NewReader("foo")
-
-	// Test that the size attribute is calculated from the content
-	// if it is not known
-	entry := newHelpersTestsMockReadableEntry(ctx, Attributes{Size: SizeUnknown})
-	entry.On("Open", ctx).Return(mockRdr, nil)
-	attr, err := Attr(ctx, entry)
-	if suite.NoError(err) {
-		suite.Equal(
-			uint64(mockRdr.Size()),
-			attr.Size,
-			"Attr should calculate the size attribute from the entry's content if its value is SizeUnknown",
-		)
-	}
-
-	// Test that the size attribute is _not_ calculated from the content
-	// if it is already known
-	entry = newHelpersTestsMockReadableEntry(ctx, Attributes{Size: 10})
-	entry.On("Open", ctx).Return(mockRdr, nil)
-	attr, err = Attr(ctx, entry)
-	if suite.NoError(err) {
-		suite.Equal(
-			uint64(10),
-			attr.Size,
-			"Attr should _not_ calculate the size attribute from the entry's content if it is already known",
-		)
-	}
-
-	// Test that Attr returns an ErrCouldNotDetermineSizeAttr error if
-	// Open errors, and that it still proceeds to fill-in the mode for FUSE
-	entry = newHelpersTestsMockReadableEntry(ctx, Attributes{Size: SizeUnknown})
-	entry.On("Open", ctx).Return(mockRdr, fmt.Errorf("could not open"))
-	attr, err = Attr(ctx, entry)
-	suite.EqualError(err, ErrCouldNotDetermineSizeAttr{"could not open"}.Error())
-	suite.Equal(
-		os.FileMode(0440),
-		attr.Mode,
-		"Attr should fill-in the Mode attribute for FUSE even when Open errors",
-	)
-
-	// Test that Attr returns an ErrNegativeSizeAttr error if the content
-	// has negative size
-	entry = newHelpersTestsMockReadableEntry(ctx, Attributes{Size: SizeUnknown})
-	entry.On("Open", ctx).Return(negativeSizedReader{}, nil)
-	attr, err = Attr(ctx, entry)
-	suite.EqualError(err, ErrNegativeSizeAttr{-1}.Error())
+func (suite *HelpersTestSuite) TestAttributes() {
+	e := newHelpersTestsMockEntry()
+	e.attr = EntryAttributes{}
+	e.attr.SetCtime(time.Now())
+	suite.Equal(e.attr, Attributes(e))
 }
 
 func (suite *HelpersTestSuite) TestExitCodeFromErr() {
diff --git a/plugin/syncableAttribute_test.go b/plugin/syncableAttribute_test.go
new file mode 100644
index 0000000..9c02a0f
--- /dev/null
+++ b/plugin/syncableAttribute_test.go
@@ -0,0 +1,111 @@
+package plugin
+
+import (
+	"os"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/suite"
+)
+
+type SyncableAttributeTestSuite struct {
+	suite.Suite
+}
+
+func (suite *SyncableAttributeTestSuite) testAttr(
+	attr *SyncableAttribute,
+	badValue interface{},
+	goodValue interface{},
+	mungedGoodValue interface{},
+) {
+	entry := NewEntry("foo")
+	meta := EntryMetadata{}
+	key := "bar"
+
+	err := attr.sync(&entry, meta, key)
+	suite.Regexp("does not.*bar.*", err)
+
+	meta[key] = nil
+	err = attr.sync(&entry, meta, key)
+	suite.Regexp("bar.*null.*", err)
+
+	meta[key] = badValue
+	err = attr.sync(&entry, meta, key)
+	suite.Regexp("munge.*bar.*key", err)
+
+	meta[key] = goodValue
+	err = attr.sync(&entry, meta, key)
+	if suite.NoError(err) {
+		expectedAttr := make(map[string]interface{})
+		expectedAttr[attr.name] = mungedGoodValue
+		expectedAttr["meta"] = EntryMetadata{}
+
+		attr := entry.attributes()
+		suite.Equal(expectedAttr, attr.toMap())
+	}
+}
+
+func (suite *SyncableAttributeTestSuite) TestAtimeAttr() {
+	t := time.Now()
+	suite.testAttr(AtimeAttr(), "foo", t, t)
+}
+
+func (suite *SyncableAttributeTestSuite) TestMtimeAttr() {
+	t := time.Now()
+	suite.testAttr(MtimeAttr(), "foo", t, t)
+}
+
+func (suite *SyncableAttributeTestSuite) TestCtimeAttr() {
+	t := time.Now()
+	suite.testAttr(CtimeAttr(), "foo", t, t)
+}
+
+func (suite *SyncableAttributeTestSuite) TestModeAttr() {
+	suite.testAttr(ModeAttr(), "badMode", "0777", os.FileMode(0777))
+}
+
+func (suite *SyncableAttributeTestSuite) TestSizeAttr() {
+	suite.testAttr(SizeAttr(), "badSize", int64(12), uint64(12))
+}
+
+func (suite *SyncableAttributeTestSuite) TestMungeToTimeVal() {
+	_, err := mungeToTimeVal("foo")
+	suite.Regexp("parse.*foo", err)
+
+	expectedTime := time.Now()
+	t, err := mungeToTimeVal(expectedTime)
+	if suite.NoError(err) {
+		suite.Equal(expectedTime, t)
+	}
+}
+
+func (suite *SyncableAttributeTestSuite) TestMungeToSizeVal() {
+	type testCase struct {
+		input    interface{}
+		expected uint64
+		errRegex string
+	}
+
+	cases := []testCase{
+		{input: uint64(10), expected: 10},
+		{input: int64(10), expected: 10},
+		{input: int(10), expected: 10},
+		{input: "foo", errRegex: "foo.*size.*uint64.*int.*int64"},
+	}
+
+	for _, c := range cases {
+		actual, err := mungeToSizeVal(c.input)
+		if c.errRegex != "" {
+			suite.Regexp(regexp.MustCompile(c.errRegex), err)
+		} else {
+			if suite.NoError(err) {
+				suite.Equal(c.expected, actual)
+			}
+		}
+	}
+}
+
+func TestSyncableAttribute(t *testing.T) {
+	suite.Run(t, new(SyncableAttributeTestSuite))
+}
diff --git a/volume/dir_test.go b/volume/dir_test.go
index e4d27c1..f026d4f 100644
--- a/volume/dir_test.go
+++ b/volume/dir_test.go
@@ -19,15 +19,15 @@ func TestVolumeDir(t *testing.T) {
 
 	assert.NotNil(t, dmap[""]["path"])
 	vd := NewDir("path", dmap[""]["path"], contentcb, "/path", dmap)
-	attr, _ := vd.Attr(context.Background())
-	assert.Equal(t, 0755|os.ModeDir, attr.Mode)
+	attr := plugin.Attributes(vd)
+	assert.Equal(t, 0755|os.ModeDir, attr.Mode())
 
 	assert.NotNil(t, dmap[""]["path1"])
 	vd = NewDir("path", dmap[""]["path1"], contentcb, "/path1", dmap)
 	entries, err := vd.List(context.Background())
 	assert.Nil(t, err)
 	assert.Equal(t, 1, len(entries))
-	assert.Equal(t, "a file", entries[0].Name())
+	assert.Equal(t, "a file", plugin.Name(entries[0]))
 	if entry, ok := entries[0].(*File); assert.Equal(t, true, ok) {
 		assert.Equal(t, "/path1/a file", entry.path)
 	}
@@ -37,7 +37,7 @@ func TestVolumeDir(t *testing.T) {
 	entries, err = vd.List(context.Background())
 	assert.Nil(t, err)
 	assert.Equal(t, 1, len(entries))
-	assert.Equal(t, "dir", entries[0].Name())
+	assert.Equal(t, "dir", plugin.Name(entries[0]))
 	if entry, ok := entries[0].(*Dir); assert.Equal(t, true, ok) {
 		assert.Equal(t, "/path2/dir", entry.path)
 	}
diff --git a/volume/file_test.go b/volume/file_test.go
index 3991111..c33a40b 100644
--- a/volume/file_test.go
+++ b/volume/file_test.go
@@ -13,13 +13,17 @@ import (
 
 func TestVolumeFile(t *testing.T) {
 	now := time.Now()
-	vf := NewFile("mine", plugin.Attributes{Ctime: now}, func(ctx context.Context, path string) (plugin.SizedReader, error) {
+	initialAttr := plugin.EntryAttributes{}
+	initialAttr.SetCtime(now)
+	vf := NewFile("mine", initialAttr, func(ctx context.Context, path string) (plugin.SizedReader, error) {
 		assert.Equal(t, "my path", path)
 		return strings.NewReader("hello"), nil
 	}, "my path")
 
-	attr, _ := vf.Attr(context.Background())
-	assert.Equal(t, plugin.Attributes{Ctime: now}, attr)
+	attr := plugin.Attributes(vf)
+	expectedAttr := plugin.EntryAttributes{}
+	expectedAttr.SetCtime(now)
+	assert.Equal(t, expectedAttr, attr)
 	rdr, err := vf.Open(context.Background())
 	assert.Nil(t, err)
 	if assert.NotNil(t, rdr) {
@@ -32,7 +36,7 @@ func TestVolumeFile(t *testing.T) {
 }
 
 func TestVolumeFileErr(t *testing.T) {
-	vf := NewFile("mine", plugin.Attributes{}, func(ctx context.Context, path string) (plugin.SizedReader, error) {
+	vf := NewFile("mine", plugin.EntryAttributes{}, func(ctx context.Context, path string) (plugin.SizedReader, error) {
 		return nil, errors.New("fail")
 	}, "my path")
 
diff --git a/volume/helpers_test.go b/volume/helpers_test.go
index 6167eb2..bb89995 100644
--- a/volume/helpers_test.go
+++ b/volume/helpers_test.go
@@ -29,27 +29,29 @@ const fixture = `
 `
 
 func TestStatParse(t *testing.T) {
-	attr, path, err := StatParse("96 1550611510 1550611448 1550611448 41ed mnt/path")
+	actualAttr, path, err := StatParse("96 1550611510 1550611448 1550611448 41ed mnt/path")
 	assert.Nil(t, err)
 	assert.Equal(t, "mnt/path", path)
-	assert.Equal(t, plugin.Attributes{
-		Atime: time.Unix(1550611510, 0),
-		Mtime: time.Unix(1550611448, 0),
-		Ctime: time.Unix(1550611448, 0),
-		Mode:  0755 | os.ModeDir,
-		Size:  96,
-	}, attr)
+	expectedAttr := plugin.EntryAttributes{}
+	expectedAttr.
+		SetAtime(time.Unix(1550611510, 0)).
+		SetMtime(time.Unix(1550611448, 0)).
+		SetCtime(time.Unix(1550611448, 0)).
+		SetMode(0755 | os.ModeDir).
+		SetSize(96)
+	assert.Equal(t, expectedAttr, actualAttr)
 
-	attr, path, err = StatParse("0 1550611458 1550611458 1550611458 81a4 mnt/path/has/got/some/legs")
+	actualAttr, path, err = StatParse("0 1550611458 1550611458 1550611458 81a4 mnt/path/has/got/some/legs")
 	assert.Nil(t, err)
 	assert.Equal(t, "mnt/path/has/got/some/legs", path)
-	assert.Equal(t, plugin.Attributes{
-		Atime: time.Unix(1550611458, 0),
-		Mtime: time.Unix(1550611458, 0),
-		Ctime: time.Unix(1550611458, 0),
-		Mode:  0644,
-		Size:  0,
-	}, attr)
+	expectedAttr = plugin.EntryAttributes{}
+	expectedAttr.
+		SetAtime(time.Unix(1550611458, 0)).
+		SetMtime(time.Unix(1550611458, 0)).
+		SetCtime(time.Unix(1550611458, 0)).
+		SetMode(0644).
+		SetSize(0)
+	assert.Equal(t, expectedAttr, actualAttr)
 
 	_, _, err = StatParse("stat: failed")
 	assert.Equal(t, errors.New("Stat did not return 6 components: stat: failed"), err)
@@ -86,27 +88,30 @@ func TestStatParseAll(t *testing.T) {
 		assert.NotNil(t, dmap[""][node])
 	}
 
-	assert.Equal(t, plugin.Attributes{
-		Atime: time.Unix(1550611453, 0),
-		Mtime: time.Unix(1550611453, 0),
-		Ctime: time.Unix(1550611453, 0),
-		Mode:  0644,
-		Size:  0,
-	}, dmap["/path1"]["a file"])
+	expectedAttr := plugin.EntryAttributes{}
+	expectedAttr.
+		SetAtime(time.Unix(1550611453, 0)).
+		SetMtime(time.Unix(1550611453, 0)).
+		SetCtime(time.Unix(1550611453, 0)).
+		SetMode(0644).
+		SetSize(0)
+	assert.Equal(t, expectedAttr, dmap["/path1"]["a file"])
 
-	assert.Equal(t, plugin.Attributes{
-		Atime: time.Unix(1550611510, 0),
-		Mtime: time.Unix(1550611441, 0),
-		Ctime: time.Unix(1550611441, 0),
-		Mode:  0755 | os.ModeDir,
-		Size:  64,
-	}, dmap["/path2"]["dir"])
+	expectedAttr = plugin.EntryAttributes{}
+	expectedAttr.
+		SetAtime(time.Unix(1550611510, 0)).
+		SetMtime(time.Unix(1550611441, 0)).
+		SetCtime(time.Unix(1550611441, 0)).
+		SetMode(0755 | os.ModeDir).
+		SetSize(64)
+	assert.Equal(t, expectedAttr, dmap["/path2"]["dir"])
 
-	assert.Equal(t, plugin.Attributes{
-		Atime: time.Unix(1550611510, 0),
-		Mtime: time.Unix(1550611448, 0),
-		Ctime: time.Unix(1550611448, 0),
-		Mode:  0755 | os.ModeDir,
-		Size:  96,
-	}, dmap["/path"]["has"])
+	expectedAttr = plugin.EntryAttributes{}
+	expectedAttr.
+		SetAtime(time.Unix(1550611510, 0)).
+		SetMtime(time.Unix(1550611448, 0)).
+		SetCtime(time.Unix(1550611448, 0)).
+		SetMode(0755 | os.ModeDir).
+		SetSize(96)
+	assert.Equal(t, expectedAttr, dmap["/path"]["has"])
 }