diff --git a/cmd/mount/dir.go b/cmd/mount/dir.go index ad657bc192..0aefc70d6a 100644 --- a/cmd/mount/dir.go +++ b/cmd/mount/dir.go @@ -17,6 +17,8 @@ import ( "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/log" "github.com/rclone/rclone/vfs" + "github.com/rclone/rclone/vfs/vfscommon" + "github.com/rclone/rclone/vfs/vfsmeta" ) // Dir represents a directory entry @@ -39,6 +41,31 @@ func (d *Dir) Attr(ctx context.Context, a *fuse.Attr) (err error) { a.Atime = modTime a.Mtime = modTime a.Ctime = modTime + opt := d.VFS().Opt + if opt.PersistMetadataEnabled() { + if m, err2 := d.VFS().LoadMetadata(ctx, d.Path(), true); err2 == nil { + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldMode) && m.Mode != nil { + perm := os.FileMode(*m.Mode) & os.ModePerm + a.Mode = (a.Mode & os.ModeType) | perm + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldOwner) { + if m.UID != nil { + a.Uid = *m.UID + } + if m.GID != nil { + a.Gid = *m.GID + } + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldTimes) { + if m.Mtime != nil { + a.Mtime = m.Mtime.UTC() + } + if m.Atime != nil { + a.Atime = m.Atime.UTC() + } + } + } + } // FIXME include Valid so get some caching? // FIXME fs.Debugf(d.path, "Dir.Attr %+v", a) return nil @@ -50,14 +77,55 @@ var _ fusefs.NodeSetattrer = (*Dir)(nil) // Setattr handles attribute changes from FUSE. Currently supports ModTime only. func (d *Dir) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) (err error) { defer log.Trace(d, "stat=%+v", req)("err=%v", &err) - if d.VFS().Opt.NoModTime { - return nil + opt := d.VFS().Opt + if !opt.NoModTime { + if req.Valid.MtimeNow() { + err = d.SetModTime(time.Now()) + } else if req.Valid.Mtime() { + err = d.SetModTime(req.Mtime) + } } - if req.Valid.MtimeNow() { - err = d.SetModTime(time.Now()) - } else if req.Valid.Mtime() { - err = d.SetModTime(req.Mtime) + if opt.PersistMetadataEnabled() { + var m vfsmeta.Meta + changed := false + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldMode) && req.Valid.Mode() { + v := uint32(req.Mode) + m.Mode = &v + changed = true + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldOwner) && req.Valid.Uid() { + v := req.Uid + m.UID = &v + changed = true + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldOwner) && req.Valid.Gid() { + v := req.Gid + m.GID = &v + changed = true + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldTimes) && (req.Valid.Atime() || req.Valid.AtimeNow()) { + t := req.Atime + if req.Valid.AtimeNow() { + t = time.Now() + } + m.Atime = &t + changed = true + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldTimes) && (req.Valid.Mtime() || req.Valid.MtimeNow()) { + t := req.Mtime + if req.Valid.MtimeNow() { + t = time.Now() + } + m.Mtime = &t + changed = true + } + if changed { + if err2 := d.VFS().SaveMetadata(ctx, d.Path(), true, m); err2 != nil { + fs.Debugf(d, "persist metadata failed: %v", err2) + } + _ = d.fsys.server.InvalidateNodeAttr(d) + } } return translateError(err) diff --git a/cmd/mount/file.go b/cmd/mount/file.go index 713ae3a9ee..74ce03892d 100644 --- a/cmd/mount/file.go +++ b/cmd/mount/file.go @@ -10,8 +10,11 @@ import ( "bazil.org/fuse" fusefs "bazil.org/fuse/fs" + "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/log" "github.com/rclone/rclone/vfs" + "github.com/rclone/rclone/vfs/vfscommon" + "github.com/rclone/rclone/vfs/vfsmeta" ) // File represents a file @@ -37,6 +40,32 @@ func (f *File) Attr(ctx context.Context, a *fuse.Attr) (err error) { a.Atime = modTime a.Mtime = modTime a.Ctime = modTime + opt := f.VFS().Opt + if opt.PersistMetadataEnabled() { + if m, err2 := f.VFS().LoadMetadata(ctx, f.Path(), false); err2 == nil { + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldMode) && m.Mode != nil { + perm := os.FileMode(*m.Mode) & os.ModePerm + a.Mode = (a.Mode & os.ModeType) | perm + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldOwner) { + if m.UID != nil { + a.Uid = *m.UID + } + if m.GID != nil { + a.Gid = *m.GID + } + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldTimes) { + if m.Mtime != nil { + a.Mtime = m.Mtime.UTC() + } + if m.Atime != nil { + a.Atime = m.Atime.UTC() + } + } + } + } + a.Blocks = Blocks return nil } @@ -47,15 +76,66 @@ var _ fusefs.NodeSetattrer = (*File)(nil) // Setattr handles attribute changes from FUSE. Currently supports ModTime and Size only func (f *File) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) (err error) { defer log.Trace(f, "a=%+v", req)("err=%v", &err) - if !f.VFS().Opt.NoModTime { + opt := f.VFS().Opt + + if req.Valid.Size() { + if e := f.File.Truncate(int64(req.Size)); err == nil { + err = e + } + } + + if !opt.NoModTime { if req.Valid.Mtime() { - err = f.File.SetModTime(req.Mtime) + if e := f.File.SetModTime(req.Mtime); err == nil { + err = e + } } else if req.Valid.MtimeNow() { - err = f.File.SetModTime(time.Now()) + if e := f.File.SetModTime(time.Now()); err == nil { + err = e + } } } - if req.Valid.Size() { - err = f.File.Truncate(int64(req.Size)) + + if opt.PersistMetadataEnabled() { + var m vfsmeta.Meta + changed := false + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldMode) && req.Valid.Mode() { + v := uint32(req.Mode) + m.Mode = &v + changed = true + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldOwner) && req.Valid.Uid() { + v := req.Uid + m.UID = &v + changed = true + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldOwner) && req.Valid.Gid() { + v := req.Gid + m.GID = &v + changed = true + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldTimes) && (req.Valid.Atime() || req.Valid.AtimeNow()) { + t := req.Atime + if req.Valid.AtimeNow() { + t = time.Now() + } + m.Atime = &t + changed = true + } + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldTimes) && (req.Valid.Mtime() || req.Valid.MtimeNow()) { + t := req.Mtime + if req.Valid.MtimeNow() { + t = time.Now() + } + m.Mtime = &t + changed = true + } + if changed { + if err2 := f.VFS().SaveMetadata(ctx, f.Path(), false, m); err2 != nil { + fs.Debugf(f, "persist metadata failed: %v", err2) + } + _ = f.fsys.server.InvalidateNodeAttr(f) + } } return translateError(err) } diff --git a/cmd/serve/nfs/filesystem.go b/cmd/serve/nfs/filesystem.go index a87563171b..877f74518c 100644 --- a/cmd/serve/nfs/filesystem.go +++ b/cmd/serve/nfs/filesystem.go @@ -3,6 +3,7 @@ package nfs import ( + "context" "os" "path" "strings" @@ -13,6 +14,7 @@ import ( "github.com/rclone/rclone/fs/log" "github.com/rclone/rclone/vfs" "github.com/rclone/rclone/vfs/vfscommon" + "github.com/rclone/rclone/vfs/vfsmeta" "github.com/willscott/go-nfs/file" ) @@ -31,14 +33,25 @@ func setSys(fi os.FileInfo) { fs.Errorf(fi, "internal error: %T is not a vfs.Node", fi) return } - vfs := node.VFS() + vv := node.VFS() + opt := vv.Opt // Set the UID and GID for the node passed in from the VFS defaults. stat := file.FileInfo{ Nlink: 1, - UID: vfs.Opt.UID, - GID: vfs.Opt.GID, + UID: opt.UID, + GID: opt.GID, Fileid: node.Inode(), // without this mounting doesn't work on Linux } + if opt.PersistMetadataEnabled() && opt.PersistMetadataIncludes(vfscommon.MetadataFieldOwner) { + if m, err := vv.LoadMetadata(context.TODO(), node.Path(), fi.IsDir()); err == nil { + if m.UID != nil { + stat.UID = *m.UID + } + if m.GID != nil { + stat.GID = *m.GID + } + } + } node.SetSys(&stat) } @@ -54,8 +67,16 @@ func (f *FS) ReadDir(path string) (dir []os.FileInfo, err error) { if err != nil { return nil, err } - for _, fi := range dir { + opt := f.vfs.Opt + for i, fi := range dir { setSys(fi) + if opt.PersistMetadataEnabled() { + if n, ok := fi.(vfs.Node); ok { + if m, err2 := f.vfs.LoadMetadata(context.TODO(), n.Path(), fi.IsDir()); err2 == nil { + dir[i] = withOverlayFileInfo(fi, m) + } + } + } } return dir, nil } @@ -86,9 +107,56 @@ func (f *FS) Stat(filename string) (fi os.FileInfo, err error) { return nil, err } setSys(fi) + // Overlay POSIX metadata on mode and times if available + if f.vfs.Opt.PersistMetadataEnabled() { + if m, err2 := f.vfs.LoadMetadata(context.TODO(), filename, fi.IsDir()); err2 == nil { + fi = withOverlayFileInfo(fi, m) + } + } return fi, nil } +// overlayFileInfo wraps os.FileInfo to override Mode and ModTime based on posix meta +type overlayFileInfo struct { + os.FileInfo + modeOverride *os.FileMode + mtimeOverride *time.Time +} + +func (o overlayFileInfo) Mode() os.FileMode { + if o.modeOverride != nil { + return *o.modeOverride + } + return o.FileInfo.Mode() +} + +func (o overlayFileInfo) ModTime() time.Time { + if o.mtimeOverride != nil { + return *o.mtimeOverride + } + return o.FileInfo.ModTime() +} + +func withOverlayFileInfo(fi os.FileInfo, m vfsmeta.Meta) os.FileInfo { + var om *os.FileMode + var mt *time.Time + if m.Mode != nil { + perm := os.FileMode(*m.Mode) & os.ModePerm + mode := (fi.Mode() & os.ModeType) | perm + om = &mode + } + if m.Mtime != nil { + t := m.Mtime.UTC() + if !t.IsZero() { + mt = &t + } + } + if om == nil && mt == nil { + return fi + } + return overlayFileInfo{FileInfo: fi, modeOverride: om, mtimeOverride: mt} +} + // Rename renames a file func (f *FS) Rename(oldpath, newpath string) (err error) { defer log.Trace(oldpath, "newpath=%q", newpath)("err=%v", &err) @@ -139,6 +207,11 @@ func (f *FS) Lstat(filename string) (fi os.FileInfo, err error) { return nil, err } setSys(fi) + if f.vfs.Opt.PersistMetadataEnabled() { + if m, err2 := f.vfs.LoadMetadata(context.TODO(), filename, fi.IsDir()); err2 == nil { + fi = withOverlayFileInfo(fi, m) + } + } return fi, nil } @@ -157,6 +230,19 @@ func (f *FS) Readlink(link string) (result string, err error) { // Chmod changes the file modes func (f *FS) Chmod(name string, mode os.FileMode) (err error) { defer log.Trace(name, "mode=%v", mode)("err=%v", &err) + node, err := f.vfs.Stat(name) + if err != nil { + return err + } + opt := f.vfs.Opt + if node.IsDir() { + if opt.PersistMetadataIncludes(vfscommon.MetadataFieldMode) { + v := uint32(mode) + m := vfsmeta.Meta{Mode: &v} + _ = f.vfs.SaveMetadata(context.TODO(), name, true, m) + } + return nil + } file, err := f.vfs.Open(name) if err != nil { return err @@ -171,6 +257,11 @@ func (f *FS) Chmod(name string, mode os.FileMode) (err error) { if err == vfs.ENOSYS { err = nil } + if err == nil && opt.PersistMetadataIncludes(vfscommon.MetadataFieldMode) { + v := uint32(mode) + m := vfsmeta.Meta{Mode: &v} + _ = f.vfs.SaveMetadata(context.TODO(), name, false, m) + } return err } @@ -183,6 +274,7 @@ func (f *FS) Lchown(name string, uid, gid int) (err error) { // Chown changes owner of the file func (f *FS) Chown(name string, uid, gid int) (err error) { defer log.Trace(name, "uid=%d, gid=%d", uid, gid)("err=%v", &err) + opt := f.vfs.Opt file, err := f.vfs.Open(name) if err != nil { return err @@ -192,13 +284,30 @@ func (f *FS) Chown(name string, uid, gid int) (err error) { fs.Logf(f, "Error while closing file: %e", err) } }() - return file.Chown(uid, gid) + err = file.Chown(uid, gid) + if err == vfs.ENOSYS { + err = nil + } + if err == nil && opt.PersistMetadataIncludes(vfscommon.MetadataFieldOwner) { + u := uint32(uid) + g := uint32(gid) + m := vfsmeta.Meta{UID: &u, GID: &g} + _ = f.vfs.SaveMetadata(context.TODO(), name, false, m) + } + return err } // Chtimes changes the access time and modified time func (f *FS) Chtimes(name string, atime time.Time, mtime time.Time) (err error) { defer log.Trace(name, "atime=%v, mtime=%v", atime, mtime)("err=%v", &err) - return f.vfs.Chtimes(name, atime, mtime) + err = f.vfs.Chtimes(name, atime, mtime) + if err == nil && f.vfs.Opt.PersistMetadataIncludes(vfscommon.MetadataFieldTimes) { + a := atime.UTC() + m := mtime.UTC() + meta := vfsmeta.Meta{Atime: &a, Mtime: &m} + _ = f.vfs.SaveMetadata(context.TODO(), name, false, meta) + } + return err } // Chroot is not supported in VFS diff --git a/docs/content/commands/rclone_mount.md b/docs/content/commands/rclone_mount.md index 10e3ce4739..57a8cec3ec 100644 --- a/docs/content/commands/rclone_mount.md +++ b/docs/content/commands/rclone_mount.md @@ -941,6 +941,15 @@ is an error reading the metadata the error will be returned as +## POSIX metadata overlay (sidecar) + +Enable a best-effort POSIX attribute overlay on mounts with `--vfs-persist-metadata`. When enabled, chmod/chown/touch operations persist to a JSON sidecar file so they survive remounts on non-POSIX backends (e.g. WebDAV). The backend’s ACLs/ownership are not modified. + +- Enable with `--vfs-persist-metadata`. +- Select the storage backend via `--vfs-metadata-store` (`backend`, `sidecar`, or `auto`). +- Configure the sidecar extension with `--vfs-metadata-extension`; it defaults to `.metadata` when persistence is enabled and the store is not `backend`. +- Metadata overlays apply after the backend’s attributes and failures are logged at debug level; FUSE operations continue even if metadata persistence fails. The overlay is independent of the `--metadata` listing feature. + ``` rclone mount remote:path /path/to/mountpoint [flags] ``` @@ -991,6 +1000,8 @@ rclone mount remote:path /path/to/mountpoint [flags] --vfs-fast-fingerprint Use fast (less accurate) fingerprints for change detection --vfs-links Translate symlinks to/from regular files with a '.rclonelink' extension for the VFS --vfs-metadata-extension string Set the extension to read metadata from + --vfs-metadata-store string Backend used for metadata persistence (default auto) + --vfs-persist-metadata Persist POSIX metadata for files --vfs-read-ahead SizeSuffix Extra read ahead over --buffer-size when using cache-mode full --vfs-read-chunk-size SizeSuffix Read the source objects in chunks (default 128Mi) --vfs-read-chunk-size-limit SizeSuffix If greater than --vfs-read-chunk-size, double the chunk size after each chunk read, until the limit is reached ('off' is unlimited) (default off) diff --git a/docs/content/commands/rclone_nfsmount.md b/docs/content/commands/rclone_nfsmount.md index 43c349c8ff..0144682ed5 100644 --- a/docs/content/commands/rclone_nfsmount.md +++ b/docs/content/commands/rclone_nfsmount.md @@ -946,6 +946,15 @@ is an error reading the metadata the error will be returned as rclone nfsmount remote:path /path/to/mountpoint [flags] ``` +## POSIX metadata overlay (sidecar) + +Enable a best-effort POSIX attribute overlay on NFS mounts with `--vfs-persist-metadata`. When enabled, chmod/chown/touch operations persist to a JSON sidecar file so they survive remounts on non-POSIX backends. The backend’s ACLs/ownership are not modified. + +- Enable with `--vfs-persist-metadata`. +- Select the storage backend via `--vfs-metadata-store` (`backend`, `sidecar`, or `auto`). +- Configure the sidecar extension with `--vfs-metadata-extension`; it defaults to `.metadata` when persistence is enabled and the store is not `backend`. +- Metadata overlays apply after the backend’s attributes and failures are logged at debug level; NFS operations continue even if metadata persistence fails. The overlay is independent of the `--metadata` listing feature. + ## Options ``` @@ -997,6 +1006,8 @@ rclone nfsmount remote:path /path/to/mountpoint [flags] --vfs-fast-fingerprint Use fast (less accurate) fingerprints for change detection --vfs-links Translate symlinks to/from regular files with a '.rclonelink' extension for the VFS --vfs-metadata-extension string Set the extension to read metadata from + --vfs-metadata-store string Backend used for metadata persistence (default auto) + --vfs-persist-metadata Persist POSIX metadata for files --vfs-read-ahead SizeSuffix Extra read ahead over --buffer-size when using cache-mode full --vfs-read-chunk-size SizeSuffix Read the source objects in chunks (default 128Mi) --vfs-read-chunk-size-limit SizeSuffix If greater than --vfs-read-chunk-size, double the chunk size after each chunk read, until the limit is reached ('off' is unlimited) (default off) diff --git a/vfs/dir.go b/vfs/dir.go index 174b7d001f..c8ff4176c1 100644 --- a/vfs/dir.go +++ b/vfs/dir.go @@ -831,6 +831,10 @@ func jsonErrorf(format string, a ...any) []byte { // // Returns true if it is a metadata name func (d *Dir) statMetadata(leaf, baseLeaf string) (metaNode Node, err error) { + // Prevent generating metadata for metadata sidecars (e.g. *.metadata.metadata) + if d.vfs.isMetaPath(path.Join(d.path, baseLeaf)) { + return nil, ENOENT + } // Find original file - note that this is recursing into stat() node, err := d.stat(baseLeaf) if err != nil { @@ -856,7 +860,8 @@ func (d *Dir) statMetadata(leaf, baseLeaf string) (metaNode Node, err error) { } // Make a memory based file with metadataDump in remote := path.Join(d.path, leaf) - o := object.NewMemoryObject(remote, entry.ModTime(context.TODO()), metadataDump) + modTime := node.ModTime() + o := object.NewMemoryObject(remote, modTime, metadataDump) f := newFile(d, d.path, o, leaf) // Base the metadata inode number off the real file inode number // to keep it constant @@ -927,7 +932,16 @@ func (d *Dir) isEmpty() (bool, error) { if err != nil { return false, err } - return len(d.items) == 0, nil + if !d.vfs.hideSidecarMetadata() { + return len(d.items) == 0, nil + } + for _, item := range d.items { + if d.shouldHideMetadataNode(item) { + continue + } + return false, nil + } + return true, nil } // ModTime returns the modification time of the directory @@ -1010,7 +1024,11 @@ func (d *Dir) ReadDirAll() (items Nodes, err error) { d.mu.Unlock() return nil, err } + hideMeta := d.vfs.hideSidecarMetadata() for _, item := range d.items { + if hideMeta && d.shouldHideMetadataNode(item) { + continue + } items = append(items, item) } d.mu.Unlock() @@ -1019,6 +1037,15 @@ func (d *Dir) ReadDirAll() (items Nodes, err error) { return items, nil } +func (d *Dir) shouldHideMetadataNode(node Node) bool { + if node == nil { + return false + } + leaf := node.Name() + full := path.Join(d.path, leaf) + return d.vfs.isMetaPath(full) +} + // accessModeMask masks off the read modes from the flags const accessModeMask = (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) diff --git a/vfs/metadata_backend.go b/vfs/metadata_backend.go new file mode 100644 index 0000000000..94647a53c2 --- /dev/null +++ b/vfs/metadata_backend.go @@ -0,0 +1,119 @@ +package vfs + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/vfs/vfsmeta" +) + +type backendStore struct { + vfs *VFS +} + +func newBackendStore(vfs *VFS) *backendStore { + return &backendStore{vfs: vfs} +} + +func (s *backendStore) Load(ctx context.Context, p string, isDir bool) (vfsmeta.Meta, error) { + entry, err := s.entry(ctx, p) + if err != nil { + return vfsmeta.Meta{}, err + } + md, err := fs.GetMetadata(ctx, entry) + if err != nil { + return vfsmeta.Meta{}, err + } + var m vfsmeta.Meta + if v, ok := md["mode"]; ok { + if n, err := strconv.ParseUint(v, 8, 32); err == nil { + u := uint32(n) + m.Mode = &u + } + } + if v, ok := md["uid"]; ok { + if n, err := strconv.ParseUint(v, 10, 32); err == nil { + u := uint32(n) + m.UID = &u + } + } + if v, ok := md["gid"]; ok { + if n, err := strconv.ParseUint(v, 10, 32); err == nil { + u := uint32(n) + m.GID = &u + } + } + if v, ok := md["mtime"]; ok { + if t, err := time.Parse(time.RFC3339Nano, v); err == nil { + m.Mtime = &t + } + } + if v, ok := md["atime"]; ok { + if t, err := time.Parse(time.RFC3339Nano, v); err == nil { + m.Atime = &t + } + } + if v, ok := md["btime"]; ok { + if t, err := time.Parse(time.RFC3339Nano, v); err == nil { + m.Btime = &t + } + } + return m, nil +} + +func (s *backendStore) Save(ctx context.Context, p string, isDir bool, m vfsmeta.Meta) error { + entry, err := s.entry(ctx, p) + if err != nil { + return err + } + md, _ := fs.GetMetadata(ctx, entry) + if md == nil { + md = fs.Metadata{} + } + if m.Mode != nil { + md["mode"] = fmt.Sprintf("%o", *m.Mode) + } + if m.UID != nil { + md["uid"] = fmt.Sprintf("%d", *m.UID) + } + if m.GID != nil { + md["gid"] = fmt.Sprintf("%d", *m.GID) + } + if m.Mtime != nil { + md["mtime"] = m.Mtime.Format(time.RFC3339Nano) + } + if m.Atime != nil { + md["atime"] = m.Atime.Format(time.RFC3339Nano) + } + if m.Btime != nil { + md["btime"] = m.Btime.Format(time.RFC3339Nano) + } + w, ok := entry.(fs.SetMetadataer) + if !ok { + return fs.ErrorNotImplemented + } + return w.SetMetadata(ctx, md) +} + +func (s *backendStore) Rename(ctx context.Context, oldPath, newPath string, isDir bool) error { + return nil +} + +func (s *backendStore) Delete(ctx context.Context, p string, isDir bool) error { + return nil +} + +func (s *backendStore) entry(ctx context.Context, p string) (fs.DirEntry, error) { + node, err := s.vfs.Stat(p) + if err != nil { + return nil, err + } + e := node.DirEntry() + if e == nil { + return nil, fs.ErrorObjectNotFound + } + return e, nil +} diff --git a/vfs/metadata_sidecar.go b/vfs/metadata_sidecar.go new file mode 100644 index 0000000000..4386c511df --- /dev/null +++ b/vfs/metadata_sidecar.go @@ -0,0 +1,210 @@ +package vfs + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/vfs/vfsmeta" +) + +type sidecarStore struct { + vfs *VFS + ext string +} + +func newSidecarStore(vfs *VFS, ext string) *sidecarStore { + return &sidecarStore{vfs: vfs, ext: ext} +} + +func (s *sidecarStore) name(p string) string { + return strings.TrimSuffix(p, "/") + s.ext +} + +func (s *sidecarStore) Load(ctx context.Context, p string, isDir bool) (vfsmeta.Meta, error) { + b, err := s.vfs.ReadFile(s.name(p)) + if err != nil { + return vfsmeta.Meta{}, err + } + meta, err := decodeSidecar(b) + if err != nil { + return vfsmeta.Meta{}, err + } + return meta, nil +} + +func (s *sidecarStore) Save(ctx context.Context, p string, isDir bool, m vfsmeta.Meta) error { + cur, err := s.Load(ctx, p, isDir) + if err != nil { + if !errors.Is(err, fs.ErrorObjectNotFound) && !errors.Is(err, ENOENT) { + return err + } + cur = vfsmeta.Meta{} + } + cur.Merge(m) + + b, err := encodeSidecar(cur) + if err != nil { + return err + } + name := s.name(p) + return s.writeWithFallback(name, b) +} + +func (s *sidecarStore) Rename(ctx context.Context, oldPath, newPath string, isDir bool) error { + return s.vfs.Rename(s.name(oldPath), s.name(newPath)) +} + +func (s *sidecarStore) Delete(ctx context.Context, p string, isDir bool) error { + name := s.name(p) + if err := s.vfs.Remove(name); err != nil { + if errors.Is(err, ENOENT) { + return nil + } + return err + } + return nil +} + +func (s *sidecarStore) renameSidecar(oldName, newName string) error { + oldDir, oldLeaf, err := s.vfs.StatParent(oldName) + if err != nil { + return err + } + newDir, newLeaf, err := s.vfs.StatParent(newName) + if err != nil { + return err + } + return oldDir.Rename(oldLeaf, newLeaf, newDir) +} + +func (s *sidecarStore) writeAtomic(name string, data []byte) error { + tmp := name + ".tmp" + if err := s.vfs.WriteFile(tmp, data, 0o600); err != nil { + return err + } + if err := s.renameSidecar(tmp, name); err != nil { + _ = s.vfs.Remove(tmp) + return fmt.Errorf("rename sidecar metadata failed: %w", err) + } + return nil +} + +func (s *sidecarStore) writeWithFallback(name string, data []byte) error { + if err := s.writeAtomic(name, data); err == nil { + return nil + } else if err != nil { + if err2 := s.vfs.WriteFile(name, data, 0o600); err2 != nil { + return fmt.Errorf("sidecar write fallback failed: %v (original error: %w)", err2, err) + } + } + return nil +} + +func encodeSidecar(meta vfsmeta.Meta) ([]byte, error) { + type diskMeta struct { + Mode *string `json:"mode,omitempty"` + UID *string `json:"uid,omitempty"` + GID *string `json:"gid,omitempty"` + Mtime *string `json:"mtime,omitempty"` + Atime *string `json:"atime,omitempty"` + Btime *string `json:"btime,omitempty"` + } + toUint := func(u *uint32) *string { + if u == nil { + return nil + } + s := strconv.FormatUint(uint64(*u), 10) + return &s + } + toTime := func(t *time.Time) *string { + if t == nil { + return nil + } + s := t.UTC().Format(time.RFC3339Nano) + return &s + } + d := diskMeta{ + Mode: toUint(meta.Mode), + UID: toUint(meta.UID), + GID: toUint(meta.GID), + Mtime: toTime(meta.Mtime), + Atime: toTime(meta.Atime), + Btime: toTime(meta.Btime), + } + return json.Marshal(d) +} + +func decodeSidecar(data []byte) (vfsmeta.Meta, error) { + type diskMeta struct { + Mode *string `json:"mode,omitempty"` + UID *string `json:"uid,omitempty"` + GID *string `json:"gid,omitempty"` + Mtime *string `json:"mtime,omitempty"` + Atime *string `json:"atime,omitempty"` + Btime *string `json:"btime,omitempty"` + } + var d diskMeta + if err := json.Unmarshal(data, &d); err != nil { + return vfsmeta.Meta{}, err + } + parseUint := func(s *string) (*uint32, error) { + if s == nil || *s == "" { + return nil, nil + } + val, err := strconv.ParseUint(*s, 10, 32) + if err != nil { + return nil, err + } + u := uint32(val) + return &u, nil + } + parseTime := func(s *string) (*time.Time, error) { + if s == nil || *s == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339Nano, *s) + if err != nil { + return nil, err + } + t = t.UTC() + return &t, nil + } + mode, err := parseUint(d.Mode) + if err != nil { + return vfsmeta.Meta{}, err + } + uid, err := parseUint(d.UID) + if err != nil { + return vfsmeta.Meta{}, err + } + gid, err := parseUint(d.GID) + if err != nil { + return vfsmeta.Meta{}, err + } + mtime, err := parseTime(d.Mtime) + if err != nil { + return vfsmeta.Meta{}, err + } + atime, err := parseTime(d.Atime) + if err != nil { + return vfsmeta.Meta{}, err + } + btime, err := parseTime(d.Btime) + if err != nil { + return vfsmeta.Meta{}, err + } + return vfsmeta.Meta{ + Mode: mode, + UID: uid, + GID: gid, + Mtime: mtime, + Atime: atime, + Btime: btime, + }, nil +} diff --git a/vfs/metadata_test.go b/vfs/metadata_test.go new file mode 100644 index 0000000000..fb676dd774 --- /dev/null +++ b/vfs/metadata_test.go @@ -0,0 +1,153 @@ +package vfs + +import ( + "context" + "testing" + "time" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/vfs/vfscommon" + "github.com/rclone/rclone/vfs/vfsmeta" + "github.com/stretchr/testify/require" +) + +func TestMetadataSidecar(t *testing.T) { + ctx := context.Background() + opt := vfscommon.Opt + opt.PersistMetadata = "all" + opt.MetadataStore = "sidecar" + r, v := newTestVFSOpt(t, &opt) + defer r.Finalise() + r.WriteObject(ctx, "file", "", time.Now()) + m := uint32(0o100600) + require.NoError(t, v.SaveMetadata(ctx, "file", false, vfsmeta.Meta{Mode: &m})) + got, err := v.LoadMetadata(ctx, "file", false) + require.NoError(t, err) + require.NotNil(t, got.Mode) + require.Equal(t, m, *got.Mode) + + uid := uint32(1010) + require.NoError(t, v.SaveMetadata(ctx, "file", false, vfsmeta.Meta{UID: &uid})) + got, err = v.LoadMetadata(ctx, "file", false) + require.NoError(t, err) + require.NotNil(t, got.Mode) + require.Equal(t, m, *got.Mode) + require.NotNil(t, got.UID) + require.Equal(t, uid, *got.UID) +} + +func TestMetadataSidecarMerge(t *testing.T) { + ctx := context.Background() + opt := vfscommon.Opt + opt.PersistMetadata = "all" + opt.MetadataStore = "sidecar" + r, v := newTestVFSOpt(t, &opt) + defer r.Finalise() + + store := newSidecarStore(v, v.Opt.MetadataExtension) + mode := uint32(0o640) + require.NoError(t, store.Save(ctx, "standalone", false, vfsmeta.Meta{Mode: &mode})) + gid := uint32(2000) + require.NoError(t, store.Save(ctx, "standalone", false, vfsmeta.Meta{GID: &gid})) + + got, err := store.Load(ctx, "standalone", false) + require.NoError(t, err) + require.NotNil(t, got.Mode) + require.Equal(t, mode, *got.Mode) + require.NotNil(t, got.GID) + require.Equal(t, gid, *got.GID) +} + +func TestMetadataSidecarSkipsMetaPath(t *testing.T) { + ctx := context.Background() + opt := vfscommon.Opt + opt.PersistMetadata = "all" + opt.MetadataStore = "sidecar" + opt.MetadataExtension = ".metadata" + r, v := newTestVFSOpt(t, &opt) + defer r.Finalise() + + r.WriteObject(ctx, "file", "", time.Now()) + + mode := uint32(0o600) + require.NoError(t, v.SaveMetadata(ctx, "file.metadata", false, vfsmeta.Meta{Mode: &mode})) + + _, err := v.ReadFile("file.metadata.metadata") + require.Error(t, err) + require.ErrorIs(t, err, ENOENT) + + _, err = v.LoadMetadata(ctx, "file.metadata", false) + require.Error(t, err) + require.ErrorIs(t, err, fs.ErrorObjectNotFound) +} + +func TestMetadataSidecarFieldSelection(t *testing.T) { + ctx := context.Background() + opt := vfscommon.Opt + opt.PersistMetadata = "owner" + opt.MetadataStore = "sidecar" + r, v := newTestVFSOpt(t, &opt) + defer r.Finalise() + + r.WriteObject(ctx, "file", "data", time.Now()) + mode := uint32(0o644) + uid := uint32(1234) + meta := vfsmeta.Meta{Mode: &mode, UID: &uid} + require.NoError(t, v.SaveMetadata(ctx, "file", false, meta)) + + got, err := v.LoadMetadata(ctx, "file", false) + require.NoError(t, err) + require.Nil(t, got.Mode) + require.NotNil(t, got.UID) + require.Equal(t, uid, *got.UID) +} + +func TestMetadataSidecarHiddenFromListing(t *testing.T) { + ctx := context.Background() + opt := vfscommon.Opt + opt.PersistMetadata = "all" + opt.MetadataStore = "sidecar" + opt.HideMetadata = true + r, v := newTestVFSOpt(t, &opt) + defer r.Finalise() + + r.WriteObject(ctx, "file", "payload", time.Now()) + mode := uint32(0o600) + require.NoError(t, v.SaveMetadata(ctx, "file", false, vfsmeta.Meta{Mode: &mode})) + + entries, err := v.ReadDir("") + require.NoError(t, err) + ext := v.Opt.MetadataExtension + for _, entry := range entries { + require.NotEqual(t, "file"+ext, entry.Name()) + } + content, err := v.ReadFile("file" + ext) + require.NoError(t, err) + require.Contains(t, string(content), "mode") +} + +func TestMetadataSidecarVisibleWhenNotHidden(t *testing.T) { + ctx := context.Background() + opt := vfscommon.Opt + opt.PersistMetadata = "all" + opt.MetadataStore = "sidecar" + opt.HideMetadata = false + r, v := newTestVFSOpt(t, &opt) + defer r.Finalise() + + r.WriteObject(ctx, "file", "payload", time.Now()) + mode := uint32(0o600) + require.NoError(t, v.SaveMetadata(ctx, "file", false, vfsmeta.Meta{Mode: &mode})) + + entries, err := v.ReadDir("") + require.NoError(t, err) + ext := v.Opt.MetadataExtension + found := false + for _, entry := range entries { + if entry.Name() == "file"+ext { + found = true + break + } + } + require.True(t, found, "expected metadata sidecar to be listed when hide flag disabled") +} diff --git a/vfs/vfs.go b/vfs/vfs.go index 1d2f71dd10..80c859473d 100644 --- a/vfs/vfs.go +++ b/vfs/vfs.go @@ -43,6 +43,7 @@ import ( "github.com/rclone/rclone/fs/walk" "github.com/rclone/rclone/vfs/vfscache" "github.com/rclone/rclone/vfs/vfscommon" + "github.com/rclone/rclone/vfs/vfsmeta" ) //go:embed vfs.md @@ -179,6 +180,7 @@ type VFS struct { root *Dir Opt vfscommon.Options cache *vfscache.Cache + metaStore vfsmeta.Store cancel context.CancelFunc cancelCache context.CancelFunc usageMu sync.Mutex @@ -214,6 +216,21 @@ func New(f fs.Fs, opt *vfscommon.Options) *VFS { // Fill out anything else vfs.Opt.Init() + if vfs.Opt.PersistMetadataEnabled() { + switch vfs.Opt.MetadataStore { + case "backend": + vfs.metaStore = newBackendStore(vfs) + case "auto": + ft := vfs.f.Features() + if ft.ReadMetadata && ft.WriteMetadata { + vfs.metaStore = newBackendStore(vfs) + } else { + vfs.metaStore = newSidecarStore(vfs, vfs.Opt.MetadataExtension) + } + default: + vfs.metaStore = newSidecarStore(vfs, vfs.Opt.MetadataExtension) + } + } // Find a VFS with the same name and options and return it if possible activeMu.Lock() @@ -605,6 +622,9 @@ func (vfs *VFS) Rename(oldName, newName string) error { if err != nil { return err } + if vfs.metaStore != nil && !vfs.isMetaPath(oldName) { + _ = vfs.metaStore.Rename(context.Background(), oldName, newName, false) + } return nil } @@ -710,6 +730,9 @@ func (vfs *VFS) Remove(name string) error { if err != nil { return err } + if vfs.metaStore != nil && !vfs.isMetaPath(name) { + _ = vfs.metaStore.Delete(context.Background(), name, node.IsDir()) + } return nil } @@ -827,6 +850,109 @@ func (vfs *VFS) WriteFile(name string, data []byte, perm os.FileMode) (err error return err } +// LoadMetadata loads merged VFS metadata for the given path. +func (vfs *VFS) LoadMetadata(ctx context.Context, path string, isDir bool) (vfsmeta.Meta, error) { + if vfs.metaStore == nil { + return vfsmeta.Meta{}, nil + } + if vfs.isMetaPath(path) { + return vfsmeta.Meta{}, fs.ErrorObjectNotFound + } + meta, err := vfs.metaStore.Load(ctx, path, isDir) + if err != nil { + return vfsmeta.Meta{}, err + } + mask := vfs.Opt.PersistMetadataFields() + meta, _ = maskMetadata(meta, mask) + return meta, nil +} + +// SaveMetadata merges and persists VFS metadata for the given path. +func (vfs *VFS) SaveMetadata(ctx context.Context, path string, isDir bool, m vfsmeta.Meta) error { + if vfs.metaStore == nil { + return nil + } + if vfs.isMetaPath(path) { + return nil + } + mask := vfs.Opt.PersistMetadataFields() + filtered, has := maskMetadata(m, mask) + if !has { + return nil + } + cur, _ := vfs.metaStore.Load(ctx, path, isDir) + cur, _ = maskMetadata(cur, mask) + cur.Merge(filtered) + cur, has = maskMetadata(cur, mask) + if !has { + return vfs.metaStore.Delete(ctx, path, isDir) + } + return vfs.metaStore.Save(ctx, path, isDir, cur) +} + +// RenameMetadata renames metadata associated with the given path. +func (vfs *VFS) RenameMetadata(ctx context.Context, oldPath, newPath string, isDir bool) error { + if vfs.metaStore == nil { + return nil + } + if vfs.isMetaPath(oldPath) || vfs.isMetaPath(newPath) { + return nil + } + return vfs.metaStore.Rename(ctx, oldPath, newPath, isDir) +} + +// DeleteMetadata deletes metadata associated with the given path. +func (vfs *VFS) DeleteMetadata(ctx context.Context, path string, isDir bool) error { + if vfs.metaStore == nil { + return nil + } + if vfs.isMetaPath(path) { + return nil + } + return vfs.metaStore.Delete(ctx, path, isDir) +} + +func (vfs *VFS) isMetaPath(path string) bool { + ext := vfs.Opt.MetadataExtension + return ext != "" && strings.HasSuffix(path, ext) +} + +func (vfs *VFS) hideSidecarMetadata() bool { + if vfs.metaStore == nil { + return false + } + if !vfs.Opt.HideMetadata { + return false + } + _, ok := vfs.metaStore.(*sidecarStore) + return ok +} + +func maskMetadata(meta vfsmeta.Meta, mask vfscommon.MetadataFields) (vfsmeta.Meta, bool) { + if !mask.Has(vfscommon.MetadataFieldMode) { + meta.Mode = nil + } + if !mask.Has(vfscommon.MetadataFieldOwner) { + meta.UID = nil + meta.GID = nil + } + if !mask.Has(vfscommon.MetadataFieldTimes) { + meta.Mtime = nil + meta.Atime = nil + meta.Btime = nil + } + return meta, !metadataEmpty(meta) +} + +func metadataEmpty(meta vfsmeta.Meta) bool { + return meta.Mode == nil && + meta.UID == nil && + meta.GID == nil && + meta.Mtime == nil && + meta.Atime == nil && + meta.Btime == nil +} + // AddVirtual adds the object (file or dir) to the directory cache // // This is used by the vfs cache to insert objects that are uploading diff --git a/vfs/vfs.md b/vfs/vfs.md index 612facf369..5c31479c32 100644 --- a/vfs/vfs.md +++ b/vfs/vfs.md @@ -87,6 +87,10 @@ find that you need one or the other or both. --vfs-cache-max-size SizeSuffix Max total size of objects in the cache (default off) --vfs-cache-min-free-space SizeSuffix Target minimum free space on the disk containing the cache (default off) --vfs-cache-poll-interval duration Interval to poll the cache for stale objects (default 1m0s) + --vfs-hide-metadata Hide metadata sidecar files from directory listings + --vfs-metadata-extension string Extension to use for metadata sidecar files + --vfs-metadata-store string Backend used for metadata persistence (default "auto") + --vfs-persist-metadata string Persist POSIX metadata (off|owner|mode|times|all or comma list) (default "off") --vfs-write-back duration Time to writeback files after last use when using cache (default 5s) ``` @@ -486,3 +490,28 @@ total 1048578 If the file has no metadata it will be returned as `{}` and if there is an error reading the metadata the error will be returned as `{"error":"error string"}`. + + +#### Persisting POSIX-like Metadata + +The VFS can persist POSIX-like attributes (mode/uid/gid/atime/mtime) separately from the +backend's native attributes and re-apply them on top of what the backend returns. + +- Select which attributes to persist with `--vfs-persist-metadata`. Accepted values are + `off`, `owner`, `mode`, `times`, `all`, or a comma-separated combination (for example + `owner,mode`). `all` enables every supported attribute; `off` disables persistence. +- Control where metadata is stored with `--vfs-metadata-store`: + - `backend`: store metadata on the backend via the remote's metadata API (if supported). + - `sidecar`/`auto` (default): store metadata in adjacent sidecar files. +- Sidecar file names are controlled by `--vfs-metadata-extension` (default `.metadata` + whenever persistence is enabled and the store is not `backend`). +- Use `--vfs-hide-metadata` to keep sidecar files out of directory listings entirely while + still allowing direct access (for example `cat path/file.metadata`). +- Sidecar JSON continues to encode numeric fields as decimal strings and times as + RFC3339 timestamps, matching the example above. +- Metadata overlay is applied after the backend reports its own attributes. This does not + alter remote ACLs or ownership; it only affects the view exposed by the VFS/NFS layers. +- Updates are best-effort. Failures are logged at debug level and do not cause FUSE/NFS + operations to fail; rclone falls back to storing the requested values in the sidecar + even when the backend cannot change them (e.g. `Chown` returning `ENOSYS`). +- The metadata overlay is independent of the `--metadata` copy/listing feature. diff --git a/vfs/vfscommon/options.go b/vfs/vfscommon/options.go index 7f222edac0..2c646944df 100644 --- a/vfs/vfscommon/options.go +++ b/vfs/vfscommon/options.go @@ -2,8 +2,10 @@ package vfscommon import ( "context" + "fmt" "os" "runtime" + "strings" "time" "github.com/rclone/rclone/fs" @@ -170,8 +172,41 @@ var OptionsInfo = fs.Options{{ Default: "", Help: "Set the extension to read metadata from.", Groups: "VFS", +}, { + Name: "vfs_metadata_store", + Default: "auto", + Help: "Backend used for metadata persistence", + Groups: "VFS", +}, { + Name: "vfs_hide_metadata", + Default: false, + Help: "Hide metadata sidecar files from directory listings", + Groups: "VFS", +}, { + Name: "vfs_persist_metadata", + Default: "off", + Help: "Persist POSIX metadata: off|owner|mode|times|all or comma-separated combination", + Groups: "VFS", }} +// MetadataFields describes which metadata attributes should be persisted. +type MetadataFields uint8 + +// Metadata fields bitmask. +const ( + MetadataFieldMode MetadataFields = 1 << iota + MetadataFieldOwner + MetadataFieldTimes +) + +// MetadataFieldAll combines all known metadata fields. +const MetadataFieldAll = MetadataFieldMode | MetadataFieldOwner | MetadataFieldTimes + +// Has reports whether the mask contains the provided field mask. +func (f MetadataFields) Has(field MetadataFields) bool { + return f&field != 0 +} + func init() { fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "vfs", Opt: &Opt, Options: OptionsInfo}) } @@ -210,6 +245,11 @@ type Options struct { FastFingerprint bool `config:"vfs_fast_fingerprint"` // if set use fast fingerprints DiskSpaceTotalSize fs.SizeSuffix `config:"vfs_disk_space_total_size"` MetadataExtension string `config:"vfs_metadata_extension"` // if set respond to files with this extension with metadata + MetadataStore string `config:"vfs_metadata_store"` + HideMetadata bool `config:"vfs_hide_metadata"` + PersistMetadata string `config:"vfs_persist_metadata"` + + persistMetadataMask MetadataFields `config:"-"` } // Opt is the default options modified by the environment variables and command line flags @@ -219,6 +259,21 @@ var Opt Options func (opt *Options) Init() { ci := fs.GetConfig(context.Background()) + mask, normalized, err := parsePersistMetadata(opt.PersistMetadata) + if err != nil { + fs.Errorf(nil, "invalid --vfs-persist-metadata value %q: %v", opt.PersistMetadata, err) + mask = 0 + } + opt.persistMetadataMask = mask + opt.PersistMetadata = normalized + + if opt.persistMetadataMask != 0 { + // Default sidecar metadata extension for vfsmeta store (non-backend) + if opt.MetadataExtension == "" && opt.MetadataStore != "backend" { + opt.MetadataExtension = ".metadata" + } + } + // Override --vfs-links with --links if set if ci.Links { opt.Links = true @@ -235,3 +290,73 @@ func (opt *Options) Init() { // Make sure links are returned as links opt.LinkPerms |= FileMode(os.ModeSymlink) } + +// PersistMetadataEnabled reports whether any metadata fields are persisted. +func (opt *Options) PersistMetadataEnabled() bool { + return opt.persistMetadataMask != 0 +} + +// PersistMetadataIncludes reports whether the given metadata field is persisted. +func (opt *Options) PersistMetadataIncludes(field MetadataFields) bool { + return opt.persistMetadataMask.Has(field) +} + +// PersistMetadataFields returns the configured metadata field mask. +func (opt *Options) PersistMetadataFields() MetadataFields { + return opt.persistMetadataMask +} + +func parsePersistMetadata(spec string) (MetadataFields, string, error) { + spec = strings.TrimSpace(spec) + if spec == "" { + return 0, "off", nil + } + lower := strings.ToLower(spec) + switch lower { + case "0", "off", "false", "none": + return 0, "off", nil + case "1", "on", "true", "all", "any": + return MetadataFieldAll, "all", nil + } + + var mask MetadataFields + var tokens []string + for _, raw := range strings.Split(spec, ",") { + token := strings.ToLower(strings.TrimSpace(raw)) + if token == "" { + continue + } + switch token { + case "owner": + mask |= MetadataFieldOwner + case "mode": + mask |= MetadataFieldMode + case "times": + mask |= MetadataFieldTimes + default: + return 0, "off", fmt.Errorf("unknown metadata field %q", token) + } + } + + if mask == 0 { + return 0, "off", nil + } + + // Canonicalize output string to predictable order. + switch mask { + case MetadataFieldAll: + return mask, "all", nil + } + + if mask.Has(MetadataFieldOwner) { + tokens = append(tokens, "owner") + } + if mask.Has(MetadataFieldMode) { + tokens = append(tokens, "mode") + } + if mask.Has(MetadataFieldTimes) { + tokens = append(tokens, "times") + } + + return mask, strings.Join(tokens, ","), nil +} diff --git a/vfs/vfsmeta/meta.go b/vfs/vfsmeta/meta.go new file mode 100644 index 0000000000..59c11804dd --- /dev/null +++ b/vfs/vfsmeta/meta.go @@ -0,0 +1,48 @@ +// Package vfsmeta defines metadata structures and store interfaces +// used by the VFS for persisting POSIX-like attributes. +package vfsmeta + +import ( + "context" + "time" +) + +// Meta holds optional POSIX-like attributes. +type Meta struct { + Mode *uint32 `json:"mode,omitempty"` + UID *uint32 `json:"uid,omitempty"` + GID *uint32 `json:"gid,omitempty"` + Mtime *time.Time `json:"mtime,omitempty"` + Atime *time.Time `json:"atime,omitempty"` + Btime *time.Time `json:"btime,omitempty"` +} + +// Merge overlays non-nil fields from d into m. +func (m *Meta) Merge(d Meta) { + if d.Mode != nil { + m.Mode = d.Mode + } + if d.UID != nil { + m.UID = d.UID + } + if d.GID != nil { + m.GID = d.GID + } + if d.Mtime != nil { + m.Mtime = d.Mtime + } + if d.Atime != nil { + m.Atime = d.Atime + } + if d.Btime != nil { + m.Btime = d.Btime + } +} + +// Store defines a metadata persistence backend. +type Store interface { + Load(ctx context.Context, path string, isDir bool) (Meta, error) + Save(ctx context.Context, path string, isDir bool, m Meta) error + Rename(ctx context.Context, oldPath, newPath string, isDir bool) error + Delete(ctx context.Context, path string, isDir bool) error +}