mirror of
https://github.com/rclone/rclone.git
synced 2025-03-12 23:29:54 +08:00
vfs: add --vfs-metadata-extension to expose metadata sidecar files
Some checks failed
Build & Push Docker Images / Build Docker Image for linux/386 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/amd64 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm/v6 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm/v7 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm64 (push) Has been cancelled
build / windows (push) Has been cancelled
build / other_os (push) Has been cancelled
build / mac_amd64 (push) Has been cancelled
build / mac_arm64 (push) Has been cancelled
build / linux (push) Has been cancelled
build / go1.23 (push) Has been cancelled
build / linux_386 (push) Has been cancelled
build / lint (push) Has been cancelled
build / android-all (push) Has been cancelled
Build & Push Docker Images / Merge & Push Final Docker Image (push) Has been cancelled
Some checks failed
Build & Push Docker Images / Build Docker Image for linux/386 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/amd64 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm/v6 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm/v7 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm64 (push) Has been cancelled
build / windows (push) Has been cancelled
build / other_os (push) Has been cancelled
build / mac_amd64 (push) Has been cancelled
build / mac_arm64 (push) Has been cancelled
build / linux (push) Has been cancelled
build / go1.23 (push) Has been cancelled
build / linux_386 (push) Has been cancelled
build / lint (push) Has been cancelled
build / android-all (push) Has been cancelled
Build & Push Docker Images / Merge & Push Final Docker Image (push) Has been cancelled
This adds --vfs-metadata-extension which can be used to expose sidecar files with file metadata in. These files don't exist in the listings until they are accessed.
This commit is contained in:
parent
09b6301664
commit
7dec72a1bf
66
vfs/dir.go
66
vfs/dir.go
@ -2,6 +2,7 @@ package vfs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@ -15,6 +16,7 @@ import (
|
|||||||
"github.com/rclone/rclone/fs/dirtree"
|
"github.com/rclone/rclone/fs/dirtree"
|
||||||
"github.com/rclone/rclone/fs/list"
|
"github.com/rclone/rclone/fs/list"
|
||||||
"github.com/rclone/rclone/fs/log"
|
"github.com/rclone/rclone/fs/log"
|
||||||
|
"github.com/rclone/rclone/fs/object"
|
||||||
"github.com/rclone/rclone/fs/operations"
|
"github.com/rclone/rclone/fs/operations"
|
||||||
"github.com/rclone/rclone/fs/walk"
|
"github.com/rclone/rclone/fs/walk"
|
||||||
"github.com/rclone/rclone/vfs/vfscommon"
|
"github.com/rclone/rclone/vfs/vfscommon"
|
||||||
@ -817,6 +819,51 @@ func (d *Dir) readDir() error {
|
|||||||
return d._readDir()
|
return d._readDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsonErrorf formats the string according to a format specifier and
|
||||||
|
// returns the resulting string as a JSON blob with key "error"
|
||||||
|
func jsonErrorf(format string, a ...any) []byte {
|
||||||
|
errMsg := fmt.Sprintf(format, a...)
|
||||||
|
jsonBlob, _ := json.MarshalIndent(map[string]string{"error": errMsg}, "", "\t")
|
||||||
|
return jsonBlob
|
||||||
|
}
|
||||||
|
|
||||||
|
// stat a single metadata item in the directory
|
||||||
|
//
|
||||||
|
// Returns true if it is a metadata name
|
||||||
|
func (d *Dir) statMetadata(leaf, baseLeaf string) (metaNode Node, err error) {
|
||||||
|
// Find original file - note that this is recursing into stat()
|
||||||
|
node, err := d.stat(baseLeaf)
|
||||||
|
if err != nil {
|
||||||
|
return node, err
|
||||||
|
}
|
||||||
|
// Read the metadata from the original entry into a JSON dump
|
||||||
|
entry := node.DirEntry()
|
||||||
|
var metadataDump []byte
|
||||||
|
if entry != nil {
|
||||||
|
metadata, err := fs.GetMetadata(context.TODO(), entry)
|
||||||
|
if err != nil {
|
||||||
|
metadataDump = jsonErrorf("failed to read metadata: %v", err)
|
||||||
|
} else if metadata == nil {
|
||||||
|
metadataDump = []byte("{}") // no metadata to read
|
||||||
|
} else {
|
||||||
|
metadataDump, err = json.MarshalIndent(metadata, "", "\t")
|
||||||
|
if err != nil {
|
||||||
|
metadataDump = jsonErrorf("failed to write metadata: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
metadataDump = []byte("{}") // no metadata yet when an object is being written
|
||||||
|
}
|
||||||
|
// Make a memory based file with metadataDump in
|
||||||
|
remote := path.Join(d.path, leaf)
|
||||||
|
o := object.NewMemoryObject(remote, entry.ModTime(context.TODO()), metadataDump)
|
||||||
|
f := newFile(d, d.path, o, leaf)
|
||||||
|
// Base the metadata inode number off the real file inode number
|
||||||
|
// to keep it constant
|
||||||
|
f.inode = node.Inode() ^ (1 << 63)
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
// stat a single item in the directory
|
// stat a single item in the directory
|
||||||
//
|
//
|
||||||
// returns ENOENT if not found.
|
// returns ENOENT if not found.
|
||||||
@ -824,22 +871,38 @@ func (d *Dir) readDir() error {
|
|||||||
// contains files with names that differ only by case.
|
// contains files with names that differ only by case.
|
||||||
func (d *Dir) stat(leaf string) (Node, error) {
|
func (d *Dir) stat(leaf string) (Node, error) {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
defer d.mu.Unlock()
|
|
||||||
err := d._readDir()
|
err := d._readDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
d.mu.Unlock()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
item, ok := d.items[leaf]
|
item, ok := d.items[leaf]
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
// Look for a metadata file
|
||||||
|
if !ok {
|
||||||
|
if baseLeaf, found := d.vfs.isMetadataFile(leaf); found {
|
||||||
|
node, err := d.statMetadata(leaf, baseLeaf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Add metadata file to directory as virtual object
|
||||||
|
d.addObject(node)
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ci := fs.GetConfig(context.TODO())
|
ci := fs.GetConfig(context.TODO())
|
||||||
normUnicode := !ci.NoUnicodeNormalization
|
normUnicode := !ci.NoUnicodeNormalization
|
||||||
normCase := ci.IgnoreCaseSync || d.vfs.Opt.CaseInsensitive
|
normCase := ci.IgnoreCaseSync || d.vfs.Opt.CaseInsensitive
|
||||||
if !ok && (normUnicode || normCase) {
|
if !ok && (normUnicode || normCase) {
|
||||||
leafNormalized := operations.ToNormal(leaf, normUnicode, normCase) // this handles both case and unicode normalization
|
leafNormalized := operations.ToNormal(leaf, normUnicode, normCase) // this handles both case and unicode normalization
|
||||||
|
d.mu.Lock()
|
||||||
for name, node := range d.items {
|
for name, node := range d.items {
|
||||||
if operations.ToNormal(name, normUnicode, normCase) == leafNormalized {
|
if operations.ToNormal(name, normUnicode, normCase) == leafNormalized {
|
||||||
if ok {
|
if ok {
|
||||||
// duplicate normalized match is an error
|
// duplicate normalized match is an error
|
||||||
|
d.mu.Unlock()
|
||||||
return nil, fmt.Errorf("duplicate filename %q detected with case/unicode normalization settings", leaf)
|
return nil, fmt.Errorf("duplicate filename %q detected with case/unicode normalization settings", leaf)
|
||||||
}
|
}
|
||||||
// found a normalized match
|
// found a normalized match
|
||||||
@ -847,6 +910,7 @@ func (d *Dir) stat(leaf string) (Node, error) {
|
|||||||
item = node
|
item = node
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@ -2,6 +2,7 @@ package vfs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@ -692,3 +693,72 @@ func TestDirEntryModTimeInvalidation(t *testing.T) {
|
|||||||
t.Error("ModTime not invalidated")
|
t.Error("ModTime not invalidated")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDirMetadataExtension(t *testing.T) {
|
||||||
|
r, vfs, dir, _ := dirCreate(t)
|
||||||
|
root, err := vfs.Root()
|
||||||
|
require.NoError(t, err)
|
||||||
|
features := r.Fremote.Features()
|
||||||
|
|
||||||
|
checkListing(t, dir, []string{"file1,14,false"})
|
||||||
|
checkListing(t, root, []string{"dir,0,true"})
|
||||||
|
|
||||||
|
node, err := vfs.Stat("dir/file1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, node.IsFile())
|
||||||
|
|
||||||
|
node, err = vfs.Stat("dir")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, node.IsDir())
|
||||||
|
|
||||||
|
// Check metadata files do not exist
|
||||||
|
_, err = vfs.Stat("dir/file1.metadata")
|
||||||
|
require.Error(t, err, ENOENT)
|
||||||
|
_, err = vfs.Stat("dir.metadata")
|
||||||
|
require.Error(t, err, ENOENT)
|
||||||
|
|
||||||
|
// Configure metadata extension
|
||||||
|
vfs.Opt.MetadataExtension = ".metadata"
|
||||||
|
|
||||||
|
// Check metadata for file does exist
|
||||||
|
node, err = vfs.Stat("dir/file1.metadata")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, node.IsFile())
|
||||||
|
size := node.Size()
|
||||||
|
assert.Greater(t, size, int64(1))
|
||||||
|
modTime := node.ModTime()
|
||||||
|
|
||||||
|
// ...and is now in the listing
|
||||||
|
checkListing(t, dir, []string{"file1,14,false", fmt.Sprintf("file1.metadata,%d,false", size)})
|
||||||
|
|
||||||
|
// ...and is a JSON blob with correct "mtime" key
|
||||||
|
blob, err := vfs.ReadFile("dir/file1.metadata")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var metadata map[string]string
|
||||||
|
err = json.Unmarshal(blob, &metadata)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if features.ReadMetadata {
|
||||||
|
assert.Equal(t, modTime.Format(time.RFC3339Nano), metadata["mtime"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check metadata for dir does exist
|
||||||
|
node, err = vfs.Stat("dir.metadata")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, node.IsFile())
|
||||||
|
size = node.Size()
|
||||||
|
assert.Greater(t, size, int64(1))
|
||||||
|
modTime = node.ModTime()
|
||||||
|
|
||||||
|
// ...and is now in the listing
|
||||||
|
checkListing(t, root, []string{"dir,0,true", fmt.Sprintf("dir.metadata,%d,false", size)})
|
||||||
|
|
||||||
|
// ...and is a JSON blob with correct "mtime" key
|
||||||
|
blob, err = vfs.ReadFile("dir.metadata")
|
||||||
|
require.NoError(t, err)
|
||||||
|
clear(metadata)
|
||||||
|
err = json.Unmarshal(blob, &metadata)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if features.ReadDirMetadata {
|
||||||
|
assert.Equal(t, modTime.Format(time.RFC3339Nano), metadata["mtime"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
12
vfs/vfs.go
12
vfs/vfs.go
@ -900,3 +900,15 @@ func (vfs *VFS) Symlink(oldname, newname string) error {
|
|||||||
_, err := vfs.CreateSymlink(oldname, newname)
|
_, err := vfs.CreateSymlink(oldname, newname)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return true if name represents a metadata file
|
||||||
|
//
|
||||||
|
// It returns the underlying path
|
||||||
|
func (vfs *VFS) isMetadataFile(name string) (rawName string, found bool) {
|
||||||
|
ext := vfs.Opt.MetadataExtension
|
||||||
|
if ext == "" {
|
||||||
|
return name, false
|
||||||
|
}
|
||||||
|
rawName, found = strings.CutSuffix(name, ext)
|
||||||
|
return rawName, found
|
||||||
|
}
|
||||||
|
40
vfs/vfs.md
40
vfs/vfs.md
@ -423,3 +423,43 @@ and compute the total used space itself.
|
|||||||
_WARNING._ Contrary to `rclone size`, this flag ignores filters so that the
|
_WARNING._ Contrary to `rclone size`, this flag ignores filters so that the
|
||||||
result is accurate. However, this is very inefficient and may cost lots of API
|
result is accurate. However, this is very inefficient and may cost lots of API
|
||||||
calls resulting in extra charges. Use it as a last resort and only with caching.
|
calls resulting in extra charges. Use it as a last resort and only with caching.
|
||||||
|
|
||||||
|
### VFS Metadata
|
||||||
|
|
||||||
|
If you use the `--vfs-metadata-extension` flag you can get the VFS to
|
||||||
|
expose files which contain the [metadata](/docs/#metadata) as a JSON
|
||||||
|
blob. These files will not appear in the directory listing, but can be
|
||||||
|
`stat`-ed and opened and once they have been they **will** appear in
|
||||||
|
directory listings until the directory cache expires.
|
||||||
|
|
||||||
|
Note that some backends won't create metadata unless you pass in the
|
||||||
|
`--metadata` flag.
|
||||||
|
|
||||||
|
For example, using `rclone mount` with `--metadata --vfs-metadata-extension .metadata`
|
||||||
|
we get
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ls -l /mnt/
|
||||||
|
total 1048577
|
||||||
|
-rw-rw-r-- 1 user user 1073741824 Mar 3 16:03 1G
|
||||||
|
|
||||||
|
$ cat /mnt/1G.metadata
|
||||||
|
{
|
||||||
|
"atime": "2025-03-04T17:34:22.317069787Z",
|
||||||
|
"btime": "2025-03-03T16:03:37.708253808Z",
|
||||||
|
"gid": "1000",
|
||||||
|
"mode": "100664",
|
||||||
|
"mtime": "2025-03-03T16:03:39.640238323Z",
|
||||||
|
"uid": "1000"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ ls -l /mnt/
|
||||||
|
total 1048578
|
||||||
|
-rw-rw-r-- 1 user user 1073741824 Mar 3 16:03 1G
|
||||||
|
-rw-rw-r-- 1 user user 185 Mar 3 16:03 1G.metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
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"}`.
|
||||||
|
|
||||||
|
@ -487,3 +487,17 @@ func TestFillInMissingSizes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVFSIsMetadataFile(t *testing.T) {
|
||||||
|
_, vfs := newTestVFS(t)
|
||||||
|
|
||||||
|
rawName, found := vfs.isMetadataFile("leaf.metadata")
|
||||||
|
assert.Equal(t, "leaf.metadata", rawName)
|
||||||
|
assert.Equal(t, false, found)
|
||||||
|
|
||||||
|
vfs.Opt.MetadataExtension = ".metadata"
|
||||||
|
|
||||||
|
rawName, found = vfs.isMetadataFile("leaf.metadata")
|
||||||
|
assert.Equal(t, "leaf", rawName)
|
||||||
|
assert.Equal(t, true, found)
|
||||||
|
}
|
||||||
|
@ -165,6 +165,11 @@ var OptionsInfo = fs.Options{{
|
|||||||
Default: getGID(),
|
Default: getGID(),
|
||||||
Help: "Override the gid field set by the filesystem (not supported on Windows)",
|
Help: "Override the gid field set by the filesystem (not supported on Windows)",
|
||||||
Groups: "VFS",
|
Groups: "VFS",
|
||||||
|
}, {
|
||||||
|
Name: "vfs_metadata_extension",
|
||||||
|
Default: "",
|
||||||
|
Help: "Set the extension to read metadata from.",
|
||||||
|
Groups: "VFS",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -204,6 +209,7 @@ type Options struct {
|
|||||||
UsedIsSize bool `config:"vfs_used_is_size"` // if true, use the `rclone size` algorithm for Used size
|
UsedIsSize bool `config:"vfs_used_is_size"` // if true, use the `rclone size` algorithm for Used size
|
||||||
FastFingerprint bool `config:"vfs_fast_fingerprint"` // if set use fast fingerprints
|
FastFingerprint bool `config:"vfs_fast_fingerprint"` // if set use fast fingerprints
|
||||||
DiskSpaceTotalSize fs.SizeSuffix `config:"vfs_disk_space_total_size"`
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opt is the default options modified by the environment variables and command line flags
|
// Opt is the default options modified by the environment variables and command line flags
|
||||||
|
Loading…
x
Reference in New Issue
Block a user