webdav: describe a listed entry the way clients expect (#10993)

* webdav: name the entry, not its path, in a listing

DAV:displayname carried the full path of every entry. A client that
takes displayname for the child's name - Windows Explorer does - then
looks for /dir/name under /dir and finds nothing, so a folder shows up
empty while the root, where the two spellings differ only by a leading
slash, still lists.

Readdir now builds its entries with toFileInfo like stat does, so a
listing and a lookup describe a child the same way, and the wrapper that
was trimming the sub-folder back off a name goes away with it.

Claude-Session: https://claude.ai/code/session_01XCeuCWpF9xo9CfyHvCQE9c

* webdav: derive an ETag when nothing hashed the entry

Uploads through this gateway carry no content MD5, so filer.ETag comes
back empty and every file in a PROPFIND answered with an empty
DAV:getetag, which is not a valid entity-tag. Report it as unimplemented
instead, the way the sub-folder wrapper already did, and webdav falls
back to modification time and size. The wrapper's copy went with it - it
swallowed the stat error a caller was meant to see.

Claude-Session: https://claude.ai/code/session_01XCeuCWpF9xo9CfyHvCQE9c
This commit is contained in:
Chris Lu
2026-08-27 16:21:14 -07:00
committed by GitHub
parent d8a189f07f
commit e9a464840c
3 changed files with 75 additions and 83 deletions
+15 -21
View File
@@ -115,6 +115,11 @@ func (fi *FileInfo) ETag(ctx context.Context) (string, error) {
if fi.err != nil {
return "", fi.err
}
if fi.etag == "" {
// nothing hashed this entry; let webdav derive one rather than
// publish an empty DAV:getetag, which is not a valid entity-tag
return "", webdav.ErrNotImplemented
}
return fi.etag, nil
}
@@ -382,11 +387,7 @@ func (fs *WebDavFileSystem) stat(ctx context.Context, fullFilePath string) (os.F
fullpath := util.FullPath(fullFilePath)
if listedFi := listedEntriesFrom(ctx).get(string(fullpath)); listedFi != nil {
// the caller's spelling of the path, trailing slash and all, is what a
// lookup would have reported back
fi := *listedFi
fi.name = string(fullpath)
return &fi, nil
return listedFi, nil
}
entry, _, _, err := filer_pb.GetEntry(ctx, fs, fullpath)
@@ -404,16 +405,19 @@ func (fs *WebDavFileSystem) stat(ctx context.Context, fullFilePath string) (os.F
}
func toFileInfo(fullpath util.FullPath, entry *filer_pb.Entry) *FileInfo {
// Name is what WebDAV publishes as DAV:displayname; clients that build a
// child URL from it reach nothing when it carries the whole path.
fp := util.FullPath(listedEntryKey(string(fullpath)))
fi := &FileInfo{
size: int64(filer.FileSize(entry)),
name: string(fullpath),
name: fp.Name(),
mode: os.FileMode(entry.Attributes.FileMode),
modifiedTime: time.Unix(entry.Attributes.Mtime, 0),
etag: filer.ETag(entry),
isDirectory: entry.IsDirectory,
}
if fi.name == "/" {
if fp == "/" {
fi.modifiedTime = time.Now()
fi.isDirectory = true
}
@@ -594,24 +598,14 @@ func (f *WebDavFile) Readdir(count int) (ret []os.FileInfo, err error) {
listed := listedEntriesFrom(ctx)
err = filer_pb.ReadDirAllEntries(ctx, f.fs, util.FullPath(dir), "", func(entry *filer_pb.Entry, isLast bool) error {
fi := FileInfo{
size: int64(filer.FileSize(entry)),
name: entry.Name,
mode: os.FileMode(entry.Attributes.FileMode),
modifiedTime: time.Unix(entry.Attributes.Mtime, 0),
isDirectory: entry.IsDirectory,
}
if !strings.HasSuffix(fi.name, "/") && fi.IsDir() {
fi.name += "/"
}
childPath := util.NewFullPath(dir, entry.Name)
fi := toFileInfo(childPath, entry)
glog.V(4).Infof("entry: %v", fi.name)
childPath := util.NewFullPath(dir, entry.Name)
listed.put(string(childPath), toFileInfo(childPath, entry))
listed.put(string(childPath), fi)
ret = append(ret, &fi)
ret = append(ret, fi)
return nil
})
if err != nil {
+56
View File
@@ -0,0 +1,56 @@
package weed_server
import (
"context"
"os"
"testing"
"golang.org/x/net/webdav"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func TestToFileInfoName(t *testing.T) {
tests := []struct {
fullpath string
want string
}{
{"/photo.jpg", "photo.jpg"},
{"/Images/photo.jpg", "photo.jpg"},
{"/Images/2026/photo.jpg", "photo.jpg"},
{"/Images", "Images"},
{"/Images/", "Images"},
{"/", ""},
}
for _, tt := range tests {
entry := &filer_pb.Entry{Name: "photo.jpg", Attributes: &filer_pb.FuseAttributes{}}
fi := toFileInfo(util.FullPath(tt.fullpath), entry)
if fi.Name() != tt.want {
t.Errorf("toFileInfo(%q).Name() = %q, want %q (DAV:displayname must not carry the path)", tt.fullpath, fi.Name(), tt.want)
}
}
}
func TestToFileInfoRootIsDirectory(t *testing.T) {
entry := &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}}
if !toFileInfo("/", entry).IsDir() {
t.Error("root is not a directory")
}
}
func TestFileInfoETag(t *testing.T) {
ctx := context.Background()
if _, err := (&FileInfo{}).ETag(ctx); err != webdav.ErrNotImplemented {
t.Errorf("empty etag returned %v, want ErrNotImplemented so webdav derives one", err)
}
if etag, err := (&FileInfo{etag: "abc"}).ETag(ctx); err != nil || etag != "abc" {
t.Errorf("ETag() = %q, %v, want \"abc\", nil", etag, err)
}
failed := &FileInfo{err: os.ErrInvalid}
if _, err := failed.ETag(ctx); err != os.ErrInvalid {
t.Errorf("ETag() = %v, want the stat error", err)
}
}
+4 -62
View File
@@ -2,10 +2,8 @@ package weed_server
import (
"context"
"io/fs"
"os"
"path"
"strings"
"golang.org/x/net/webdav"
)
@@ -23,10 +21,8 @@ func (w wrappedFs) confine(name string) string {
return w.subFolder + path.Clean("/"+name)
}
// NewWrappedFs returns a webdav.FileSystem identical to fs, except it
// provides access to a sub-folder of fs that is denominated by subFolder.
// It transparently handles renaming paths and filenames so that the outer part of the wrapped filesystem
// does not leak out.
// NewWrappedFs returns a webdav.FileSystem identical to fs, except that it
// serves only the subFolder sub-tree of fs.
func NewWrappedFs(fs webdav.FileSystem, subFolder string) webdav.FileSystem {
return wrappedFs{
subFolder: subFolder,
@@ -41,13 +37,7 @@ func (w wrappedFs) Mkdir(ctx context.Context, name string, perm os.FileMode) err
func (w wrappedFs) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
name = w.confine(name)
file, err := w.FileSystem.OpenFile(ctx, name, flag, perm)
file = wrappedFile{
File: file,
subFolder: &w.subFolder,
}
return file, err
return w.FileSystem.OpenFile(ctx, name, flag, perm)
}
func (w wrappedFs) RemoveAll(ctx context.Context, name string) error {
@@ -63,53 +53,5 @@ func (w wrappedFs) Rename(ctx context.Context, oldName, newName string) error {
func (w wrappedFs) Stat(ctx context.Context, name string) (os.FileInfo, error) {
name = w.confine(name)
info, err := w.FileSystem.Stat(ctx, name)
info = wrappedFileInfo{
subFolder: &w.subFolder,
FileInfo: info,
}
return info, err
}
type wrappedFile struct {
webdav.File
subFolder *string
}
func (w wrappedFile) Readdir(count int) ([]fs.FileInfo, error) {
infos, err := w.File.Readdir(count)
for i, info := range infos {
infos[i] = wrappedFileInfo{
subFolder: w.subFolder,
FileInfo: info,
}
}
return infos, err
}
func (w wrappedFile) Stat() (fs.FileInfo, error) {
info, err := w.File.Stat()
info = wrappedFileInfo{
subFolder: w.subFolder,
FileInfo: info,
}
return info, err
}
type wrappedFileInfo struct {
subFolder *string
fs.FileInfo
}
func (w wrappedFileInfo) Name() string {
name := w.FileInfo.Name()
return strings.TrimPrefix(name, *w.subFolder)
}
func (w wrappedFileInfo) ETag(ctx context.Context) (string, error) {
etag, _ := w.FileInfo.(webdav.ETager).ETag(ctx)
if len(etag) == 0 {
return etag, webdav.ErrNotImplemented
}
return etag, nil
return w.FileSystem.Stat(ctx, name)
}