vfs: add --vfs-metadata-extension to expose metadata sidecar files

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:
Nick Craig-Wood
2025-04-07 13:41:29 +01:00
parent b587b094c9
commit 533c6438f3
6 changed files with 207 additions and 1 deletions
+65 -1
View File
@@ -2,6 +2,7 @@ package vfs
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
@@ -15,6 +16,7 @@ import (
"github.com/rclone/rclone/fs/dirtree"
"github.com/rclone/rclone/fs/list"
"github.com/rclone/rclone/fs/log"
"github.com/rclone/rclone/fs/object"
"github.com/rclone/rclone/fs/operations"
"github.com/rclone/rclone/fs/walk"
"github.com/rclone/rclone/vfs/vfscommon"
@@ -817,6 +819,51 @@ func (d *Dir) readDir() error {
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
//
// returns ENOENT if not found.
@@ -824,22 +871,38 @@ func (d *Dir) readDir() error {
// contains files with names that differ only by case.
func (d *Dir) stat(leaf string) (Node, error) {
d.mu.Lock()
defer d.mu.Unlock()
err := d._readDir()
if err != nil {
d.mu.Unlock()
return nil, err
}
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())
normUnicode := !ci.NoUnicodeNormalization
normCase := ci.IgnoreCaseSync || d.vfs.Opt.CaseInsensitive
if !ok && (normUnicode || normCase) {
leafNormalized := operations.ToNormal(leaf, normUnicode, normCase) // this handles both case and unicode normalization
d.mu.Lock()
for name, node := range d.items {
if operations.ToNormal(name, normUnicode, normCase) == leafNormalized {
if ok {
// duplicate normalized match is an error
d.mu.Unlock()
return nil, fmt.Errorf("duplicate filename %q detected with case/unicode normalization settings", leaf)
}
// found a normalized match
@@ -847,6 +910,7 @@ func (d *Dir) stat(leaf string) (Node, error) {
item = node
}
}
d.mu.Unlock()
}
if !ok {