From cca1555cc79034625deadb7772d537875965a093 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 24 Mar 2026 11:43:41 -0700 Subject: [PATCH] mount: implement create for rsync temp files (#8749) * mount: implement create for rsync temp files * mount: move access implementation out of unsupported * mount: tighten access checks * mount: log access group lookup failures * mount: reset dirty pages on truncate * mount: tighten create and root access handling * mount: handle existing creates before quota checks * mount: restrict access fallback when group lookup fails When lookupSupplementaryGroupIDs returns an error, the previous code fell through to checking only the "other" permission bits, which could overgrant access. Require both group and other permission classes to satisfy the mask so access is never broader than intended. * mount: guard against nil entry in Create existing-file path maybeLoadEntry can return OK with a nil entry or nil Attributes in edge cases. Check before dereferencing to prevent a panic. * mount: reopen existing file on create race without O_EXCL When createRegularFile returns EEXIST because another process won the race, and O_EXCL is not set, reload the winner's entry and open it instead of propagating the error to the caller. * mount: check parent directory permission in createRegularFile Verify the caller has write+search (W_OK|X_OK) permission on the parent directory before creating a file. This applies to both Create and Mknod. Update test fixture mount mode to 0o777 so the existing tests pass with the new check. * mount: enforce file permission bits in AcquireHandle Map the open flags (O_RDONLY/O_WRONLY/O_RDWR) to an access mask and call hasAccess before handing out a file handle. This makes AcquireHandle the single source of truth for mode-based access control across Open, Create-existing, and Create-new paths. --------- Co-authored-by: Copilot --- go.mod | 2 +- go.sum | 2 + weed/mount/filehandle.go | 7 + weed/mount/weedfs_access.go | 99 ++++++++ weed/mount/weedfs_file_io.go | 15 +- weed/mount/weedfs_file_mkrm.go | 265 +++++++++++++++----- weed/mount/weedfs_file_mkrm_test.go | 370 ++++++++++++++++++++++++++++ weed/mount/weedfs_filehandle.go | 6 + weed/mount/weedfs_unsupported.go | 13 - 9 files changed, 688 insertions(+), 91 deletions(-) create mode 100644 weed/mount/weedfs_access.go create mode 100644 weed/mount/weedfs_file_mkrm_test.go diff --git a/go.mod b/go.mod index 60aa3f032..65663e221 100644 --- a/go.mod +++ b/go.mod @@ -149,7 +149,7 @@ require ( github.com/rdleal/intervalst v1.5.0 github.com/redis/go-redis/v9 v9.18.0 github.com/schollz/progressbar/v3 v3.19.0 - github.com/seaweedfs/go-fuse/v2 v2.9.1 + github.com/seaweedfs/go-fuse/v2 v2.9.2 github.com/shirou/gopsutil/v4 v4.26.2 github.com/tarantool/go-tarantool/v2 v2.4.2 github.com/testcontainers/testcontainers-go v0.40.0 diff --git a/go.sum b/go.sum index a56204a96..ddcab0f1f 100644 --- a/go.sum +++ b/go.sum @@ -1840,6 +1840,8 @@ github.com/seaweedfs/cockroachdb-parser v0.0.0-20260225204133-2f342c5ea564 h1:Tg github.com/seaweedfs/cockroachdb-parser v0.0.0-20260225204133-2f342c5ea564/go.mod h1:JSKCh6uCHBz91lQYFYHCyTrSVIPge4SUFVn28iwMNB0= github.com/seaweedfs/go-fuse/v2 v2.9.1 h1:gnKmfrKreCRGJmekGz5WMnNZqXEf9s9+V2hdWQdvx88= github.com/seaweedfs/go-fuse/v2 v2.9.1/go.mod h1:zABdmWEa6A0bwaBeEOBUeUkGIZlxUhcdv+V1Dcc/U/I= +github.com/seaweedfs/go-fuse/v2 v2.9.2 h1:IfP/yFjLGO4rALcJY2Gb39PlebHxLnj7dkIiQAjFres= +github.com/seaweedfs/go-fuse/v2 v2.9.2/go.mod h1:zABdmWEa6A0bwaBeEOBUeUkGIZlxUhcdv+V1Dcc/U/I= github.com/seaweedfs/goexif v1.0.3 h1:ve/OjI7dxPW8X9YQsv3JuVMaxEyF9Rvfd04ouL+Bz30= github.com/seaweedfs/goexif v1.0.3/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9zQeff7Fk= github.com/seaweedfs/raft v1.1.7 h1:3mVJZ2p4rdvBtbbrHROPjYKtH+q5qjMBc56G6VRu1kA= diff --git a/weed/mount/filehandle.go b/weed/mount/filehandle.go index 135b1807e..6ac75f532 100644 --- a/weed/mount/filehandle.go +++ b/weed/mount/filehandle.go @@ -112,6 +112,13 @@ func (fh *FileHandle) SetEntry(entry *filer_pb.Entry) { fh.invalidateChunkCache() } +func (fh *FileHandle) ResetDirtyPages() { + fh.dirtyPages.Destroy() + fh.dirtyPages = newPageWriter(fh, fh.wfs.option.ChunkSizeLimit) + fh.dirtyMetadata = false + fh.contentType = "" +} + func (fh *FileHandle) UpdateEntry(fn func(entry *filer_pb.Entry)) *filer_pb.Entry { result := fh.entry.UpdateEntry(fn) diff --git a/weed/mount/weedfs_access.go b/weed/mount/weedfs_access.go new file mode 100644 index 000000000..fede73f4e --- /dev/null +++ b/weed/mount/weedfs_access.go @@ -0,0 +1,99 @@ +package mount + +import ( + "os/user" + "strconv" + "syscall" + + "github.com/seaweedfs/go-fuse/v2/fuse" + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +var lookupSupplementaryGroupIDs = func(callerUid uint32) ([]string, error) { + u, err := user.LookupId(strconv.Itoa(int(callerUid))) + if err != nil { + glog.Warningf("hasAccess: user.LookupId for uid %d failed: %v", callerUid, err) + return nil, err + } + groupIDs, err := u.GroupIds() + if err != nil { + glog.Warningf("hasAccess: u.GroupIds for uid %d failed: %v", callerUid, err) + return nil, err + } + return groupIDs, nil +} + +/** + * Check file access permissions + * + * This will be called for the access() system call. If the + * 'default_permissions' mount option is given, this method is not + * called. + * + * This method is not called under Linux kernel versions 2.4.x + */ +func (wfs *WFS) Access(cancel <-chan struct{}, input *fuse.AccessIn) (code fuse.Status) { + _, _, entry, code := wfs.maybeReadEntry(input.NodeId) + if code != fuse.OK { + return code + } + if entry == nil || entry.Attributes == nil { + return fuse.EIO + } + if hasAccess(input.Uid, input.Gid, entry.Attributes.Uid, entry.Attributes.Gid, entry.Attributes.FileMode, input.Mask) { + return fuse.OK + } + return fuse.EACCES +} + +func hasAccess(callerUid, callerGid, fileUid, fileGid uint32, perm uint32, mask uint32) bool { + mask &= fuse.R_OK | fuse.W_OK | fuse.X_OK + if mask == 0 { + return true + } + if callerUid == 0 { + return mask&fuse.X_OK == 0 || perm&0o111 != 0 + } + + if callerUid == fileUid { + return (perm>>6)&mask == mask + } + + isMember := callerGid == fileGid + if !isMember { + groupIDs, err := lookupSupplementaryGroupIDs(callerUid) + if err != nil { + // Cannot determine group membership; require both group and + // other permission classes to satisfy the mask so we never + // overgrant when the lookup fails. + groupMatch := ((perm >> 3) & mask) == mask + otherMatch := (perm & mask) == mask + return groupMatch && otherMatch + } + fileGidStr := strconv.Itoa(int(fileGid)) + for _, gidStr := range groupIDs { + if gidStr == fileGidStr { + isMember = true + break + } + } + } + + if isMember { + return (perm>>3)&mask == mask + } + + return (perm & mask) == mask +} + +// openFlagsToAccessMask converts open(2) flags to an access permission mask. +func openFlagsToAccessMask(flags uint32) uint32 { + switch flags & uint32(syscall.O_ACCMODE) { + case syscall.O_WRONLY: + return fuse.W_OK + case syscall.O_RDWR: + return fuse.R_OK | fuse.W_OK + default: // O_RDONLY + return fuse.R_OK + } +} diff --git a/weed/mount/weedfs_file_io.go b/weed/mount/weedfs_file_io.go index 47a9d6df7..cd63e6805 100644 --- a/weed/mount/weedfs_file_io.go +++ b/weed/mount/weedfs_file_io.go @@ -1,9 +1,6 @@ package mount -import ( - "github.com/seaweedfs/go-fuse/v2/fuse" - "github.com/seaweedfs/seaweedfs/weed/glog" -) +import "github.com/seaweedfs/go-fuse/v2/fuse" /** * Open a file @@ -66,15 +63,7 @@ func (wfs *WFS) Open(cancel <-chan struct{}, in *fuse.OpenIn, out *fuse.OpenOut) fileHandle, status = wfs.AcquireHandle(in.NodeId, in.Flags, in.Uid, in.Gid) if status == fuse.OK { out.Fh = uint64(fileHandle.fh) - out.OpenFlags = in.Flags - if wfs.option.IsMacOs { - // remove the direct_io flag, as it is not well-supported on macOS - // https://code.google.com/archive/p/macfuse/wikis/OPTIONS.wiki recommended to avoid the direct_io flag - if in.Flags&fuse.FOPEN_DIRECT_IO != 0 { - glog.V(4).Infof("macfuse direct_io mode %v => false\n", in.Flags&fuse.FOPEN_DIRECT_IO != 0) - out.OpenFlags &^= fuse.FOPEN_DIRECT_IO - } - } + out.OpenFlags = 0 // TODO https://github.com/libfuse/libfuse/blob/master/include/fuse_common.h#L64 } return status diff --git a/weed/mount/weedfs_file_mkrm.go b/weed/mount/weedfs_file_mkrm.go index 17f4e1ac8..122834573 100644 --- a/weed/mount/weedfs_file_mkrm.go +++ b/weed/mount/weedfs_file_mkrm.go @@ -8,6 +8,7 @@ import ( "github.com/seaweedfs/go-fuse/v2/fuse" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/util" ) /** @@ -21,92 +22,117 @@ import ( * will be called instead. */ func (wfs *WFS) Create(cancel <-chan struct{}, in *fuse.CreateIn, name string, out *fuse.CreateOut) (code fuse.Status) { - // if implemented, need to use - // inode := wfs.inodeToPath.Lookup(entryFullPath) - // to ensure nlookup counter - return fuse.ENOSYS -} - -/** Create a file node - * - * This is called for creation of all non-directory, non-symlink - * nodes. If the filesystem defines a create() method, then for - * regular files that will be called instead. - */ -func (wfs *WFS) Mknod(cancel <-chan struct{}, in *fuse.MknodIn, name string, out *fuse.EntryOut) (code fuse.Status) { - - if wfs.IsOverQuotaWithUncommitted() { - return fuse.Status(syscall.ENOSPC) - } - if s := checkName(name); s != fuse.OK { return s } dirFullPath, code := wfs.inodeToPath.GetPath(in.NodeId) if code != fuse.OK { - return + return code } entryFullPath := dirFullPath.Child(name) - fileMode := toOsFileMode(in.Mode) - now := time.Now().Unix() - inode := wfs.inodeToPath.AllocateInode(entryFullPath, now) - - newEntry := &filer_pb.Entry{ - Name: name, - IsDirectory: false, - Attributes: &filer_pb.FuseAttributes{ - Mtime: now, - Crtime: now, - FileMode: uint32(fileMode), - Uid: in.Uid, - Gid: in.Gid, - TtlSec: wfs.option.TtlSec, - Rdev: in.Rdev, - Inode: inode, - }, - } + var inode uint64 - err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + newEntry, code := wfs.maybeLoadEntry(entryFullPath) + if code == fuse.OK { + if newEntry == nil || newEntry.Attributes == nil { + return fuse.EIO + } + if in.Flags&syscall.O_EXCL != 0 { + return fuse.Status(syscall.EEXIST) + } + inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, len(newEntry.HardLinkId) > 0, newEntry.Attributes.Inode, true) + fileHandle, status := wfs.AcquireHandle(inode, in.Flags, in.Uid, in.Gid) + if status != fuse.OK { + return status + } + if in.Flags&syscall.O_TRUNC != 0 && in.Flags&fuse.O_ANYWRITE != 0 { + if code = wfs.truncateEntry(entryFullPath, newEntry); code != fuse.OK { + wfs.ReleaseHandle(fileHandle.fh) + return code + } + newEntry = fileHandle.GetEntry().GetEntry() + } - wfs.mapPbIdFromLocalToFiler(newEntry) - defer wfs.mapPbIdFromFilerToLocal(newEntry) + wfs.outputPbEntry(&out.EntryOut, inode, newEntry) + out.Fh = uint64(fileHandle.fh) + out.OpenFlags = 0 + return fuse.OK + } + if code != fuse.ENOENT { + return code + } - request := &filer_pb.CreateEntryRequest{ - Directory: string(dirFullPath), - Entry: newEntry, - Signatures: []int32{wfs.signature}, - SkipCheckParentDirectory: true, + inode, newEntry, code = wfs.createRegularFile(dirFullPath, name, in.Mode, in.Uid, in.Gid, 0) + if code == fuse.Status(syscall.EEXIST) && in.Flags&syscall.O_EXCL == 0 { + // Race: another process created the file between our check and create. + // Reopen the winner's entry. + newEntry, code = wfs.maybeLoadEntry(entryFullPath) + if code != fuse.OK { + return code } - - glog.V(1).Infof("mknod: %v", request) - resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request) - if err != nil { - glog.V(0).Infof("mknod %s: %v", entryFullPath, err) - return err + if newEntry == nil || newEntry.Attributes == nil { + return fuse.EIO } - - event := resp.GetMetadataEvent() - if event == nil { - event = metadataCreateEvent(string(dirFullPath), newEntry) + inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, len(newEntry.HardLinkId) > 0, newEntry.Attributes.Inode, true) + fileHandle, status := wfs.AcquireHandle(inode, in.Flags, in.Uid, in.Gid) + if status != fuse.OK { + return status } - if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil { - glog.Warningf("mknod %s: best-effort metadata apply failed: %v", entryFullPath, applyErr) - wfs.inodeToPath.InvalidateChildrenCache(dirFullPath) + if in.Flags&syscall.O_TRUNC != 0 && in.Flags&fuse.O_ANYWRITE != 0 { + if code = wfs.truncateEntry(entryFullPath, newEntry); code != fuse.OK { + wfs.ReleaseHandle(fileHandle.fh) + return code + } + newEntry = fileHandle.GetEntry().GetEntry() } - wfs.inodeToPath.TouchDirectory(dirFullPath) + wfs.outputPbEntry(&out.EntryOut, inode, newEntry) + out.Fh = uint64(fileHandle.fh) + out.OpenFlags = 0 + return fuse.OK + } else if code != fuse.OK { + return code + } else { + inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, false, inode, true) + } - return nil - }) + wfs.outputPbEntry(&out.EntryOut, inode, newEntry) - glog.V(3).Infof("mknod %s: %v", entryFullPath, err) + fileHandle, status := wfs.AcquireHandle(inode, in.Flags, in.Uid, in.Gid) + if status != fuse.OK { + return status + } + out.Fh = uint64(fileHandle.fh) + out.OpenFlags = 0 - if err != nil { - return fuse.EIO + return fuse.OK +} + +/** Create a file node + * + * This is called for creation of all non-directory, non-symlink + * nodes. If the filesystem defines a create() method, then for + * regular files that will be called instead. + */ +func (wfs *WFS) Mknod(cancel <-chan struct{}, in *fuse.MknodIn, name string, out *fuse.EntryOut) (code fuse.Status) { + + if s := checkName(name); s != fuse.OK { + return s + } + + dirFullPath, code := wfs.inodeToPath.GetPath(in.NodeId) + if code != fuse.OK { + return + } + + inode, newEntry, code := wfs.createRegularFile(dirFullPath, name, in.Mode, in.Uid, in.Gid, in.Rdev) + if code != fuse.OK { + return code } // this is to increase nlookup counter + entryFullPath := dirFullPath.Child(name) inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, false, inode, true) wfs.outputPbEntry(out, inode, newEntry) @@ -175,3 +201,114 @@ func (wfs *WFS) Unlink(cancel <-chan struct{}, header *fuse.InHeader, name strin return fuse.OK } + +func (wfs *WFS) createRegularFile(dirFullPath util.FullPath, name string, mode uint32, uid, gid, rdev uint32) (inode uint64, newEntry *filer_pb.Entry, code fuse.Status) { + if wfs.IsOverQuotaWithUncommitted() { + return 0, nil, fuse.Status(syscall.ENOSPC) + } + + // Verify write+search permission on the parent directory. + parentEntry, parentStatus := wfs.maybeLoadEntry(dirFullPath) + if parentStatus != fuse.OK { + return 0, nil, parentStatus + } + if parentEntry == nil || parentEntry.Attributes == nil { + return 0, nil, fuse.EIO + } + if !hasAccess(uid, gid, parentEntry.Attributes.Uid, parentEntry.Attributes.Gid, parentEntry.Attributes.FileMode, fuse.W_OK|fuse.X_OK) { + return 0, nil, fuse.Status(syscall.EACCES) + } + + entryFullPath := dirFullPath.Child(name) + if _, status := wfs.maybeLoadEntry(entryFullPath); status == fuse.OK { + return 0, nil, fuse.Status(syscall.EEXIST) + } else if status != fuse.ENOENT { + return 0, nil, status + } + fileMode := toOsFileMode(mode) + now := time.Now().Unix() + inode = wfs.inodeToPath.AllocateInode(entryFullPath, now) + + newEntry = &filer_pb.Entry{ + Name: name, + IsDirectory: false, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + Crtime: now, + FileMode: uint32(fileMode), + Uid: uid, + Gid: gid, + TtlSec: wfs.option.TtlSec, + Rdev: rdev, + Inode: inode, + }, + } + + err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + wfs.mapPbIdFromLocalToFiler(newEntry) + defer wfs.mapPbIdFromFilerToLocal(newEntry) + + request := &filer_pb.CreateEntryRequest{ + Directory: string(dirFullPath), + Entry: newEntry, + Signatures: []int32{wfs.signature}, + SkipCheckParentDirectory: true, + } + + glog.V(1).Infof("createFile: %v", request) + resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request) + if err != nil { + glog.V(0).Infof("createFile %s: %v", entryFullPath, err) + return err + } + + event := resp.GetMetadataEvent() + if event == nil { + event = metadataCreateEvent(string(dirFullPath), newEntry) + } + if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil { + glog.Warningf("createFile %s: best-effort metadata apply failed: %v", entryFullPath, applyErr) + wfs.inodeToPath.InvalidateChildrenCache(dirFullPath) + } + wfs.inodeToPath.TouchDirectory(dirFullPath) + + return nil + }) + + glog.V(3).Infof("createFile %s: %v", entryFullPath, err) + + if err != nil { + return 0, nil, grpcErrorToFuseStatus(err) + } + + return inode, newEntry, fuse.OK +} + +func (wfs *WFS) truncateEntry(entryFullPath util.FullPath, entry *filer_pb.Entry) fuse.Status { + if entry == nil { + return fuse.EIO + } + if entry.Attributes == nil { + entry.Attributes = &filer_pb.FuseAttributes{} + } + + entry.Content = nil + entry.Chunks = nil + entry.Attributes.FileSize = 0 + entry.Attributes.Mtime = time.Now().Unix() + + if code := wfs.saveEntry(entryFullPath, entry); code != fuse.OK { + return code + } + + if inode, found := wfs.inodeToPath.GetInode(entryFullPath); found { + if fh, fhFound := wfs.fhMap.FindFileHandle(inode); fhFound { + fhActiveLock := fh.wfs.fhLockTable.AcquireLock("truncateEntry", fh.fh, util.ExclusiveLock) + fh.ResetDirtyPages() + fh.SetEntry(entry) + fh.wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock) + } + } + + return fuse.OK +} diff --git a/weed/mount/weedfs_file_mkrm_test.go b/weed/mount/weedfs_file_mkrm_test.go new file mode 100644 index 000000000..36a7cdf0a --- /dev/null +++ b/weed/mount/weedfs_file_mkrm_test.go @@ -0,0 +1,370 @@ +package mount + +import ( + "context" + "net" + "path/filepath" + "sync" + "syscall" + "testing" + "time" + + "github.com/seaweedfs/go-fuse/v2/fuse" + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/mount/meta_cache" + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/util" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +type createEntryTestServer struct { + filer_pb.UnimplementedSeaweedFilerServer + mu sync.Mutex + lastDirectory string + lastName string + lastUID uint32 + lastGID uint32 + lastMode uint32 +} + +type createEntrySnapshot struct { + directory string + name string + uid uint32 + gid uint32 + mode uint32 +} + +func (s *createEntryTestServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastDirectory = req.GetDirectory() + if req.GetEntry() != nil { + s.lastName = req.GetEntry().GetName() + if req.GetEntry().GetAttributes() != nil { + s.lastUID = req.GetEntry().GetAttributes().GetUid() + s.lastGID = req.GetEntry().GetAttributes().GetGid() + s.lastMode = req.GetEntry().GetAttributes().GetFileMode() + } + } + return &filer_pb.CreateEntryResponse{}, nil +} + +func (s *createEntryTestServer) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) { + return &filer_pb.UpdateEntryResponse{}, nil +} + +func (s *createEntryTestServer) snapshot() createEntrySnapshot { + s.mu.Lock() + defer s.mu.Unlock() + return createEntrySnapshot{ + directory: s.lastDirectory, + name: s.lastName, + uid: s.lastUID, + gid: s.lastGID, + mode: s.lastMode, + } +} + +func newCreateTestWFS(t *testing.T) (*WFS, *createEntryTestServer) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + }) + + server := pb.NewGrpcServer() + testServer := &createEntryTestServer{} + filer_pb.RegisterSeaweedFilerServer(server, testServer) + go server.Serve(listener) + t.Cleanup(server.Stop) + + uidGidMapper, err := meta_cache.NewUidGidMapper("", "") + if err != nil { + t.Fatalf("create uid/gid mapper: %v", err) + } + + root := util.FullPath("/") + option := &Option{ + ChunkSizeLimit: 1024, + ConcurrentReaders: 1, + VolumeServerAccess: "filerProxy", + FilerAddresses: []pb.ServerAddress{ + pb.NewServerAddressWithGrpcPort("127.0.0.1:1", listener.Addr().(*net.TCPAddr).Port), + }, + GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()), + FilerMountRootPath: "/", + MountUid: 99, + MountGid: 100, + MountMode: 0o777, + MountMtime: time.Now(), + MountCtime: time.Now(), + UidGidMapper: uidGidMapper, + uniqueCacheDirForWrite: t.TempDir(), + } + + wfs := &WFS{ + option: option, + signature: 1, + inodeToPath: NewInodeToPath(root, 0), + fhMap: NewFileHandleToInode(), + fhLockTable: util.NewLockTable[FileHandleId](), + } + wfs.metaCache = meta_cache.NewMetaCache( + filepath.Join(t.TempDir(), "meta"), + uidGidMapper, + root, + func(path util.FullPath) { + wfs.inodeToPath.MarkChildrenCached(path) + }, + func(path util.FullPath) bool { + return wfs.inodeToPath.IsChildrenCached(path) + }, + func(util.FullPath, *filer_pb.Entry) {}, + nil, + ) + wfs.inodeToPath.MarkChildrenCached(root) + t.Cleanup(func() { + wfs.metaCache.Shutdown() + }) + + return wfs, testServer +} + +func TestCreateCreatesAndOpensFile(t *testing.T) { + wfs, testServer := newCreateTestWFS(t) + + out := &fuse.CreateOut{} + status := wfs.Create(make(chan struct{}), &fuse.CreateIn{ + InHeader: fuse.InHeader{ + NodeId: 1, + Caller: fuse.Caller{ + Owner: fuse.Owner{ + Uid: 123, + Gid: 456, + }, + }, + }, + Flags: syscall.O_WRONLY | syscall.O_CREAT, + Mode: 0o640, + }, "hello.txt", out) + if status != fuse.OK { + t.Fatalf("Create status = %v, want OK", status) + } + if out.NodeId == 0 { + t.Fatal("Create returned zero inode") + } + if out.Fh == 0 { + t.Fatal("Create returned zero file handle") + } + if out.OpenFlags != 0 { + t.Fatalf("Create returned OpenFlags = %#x, want 0", out.OpenFlags) + } + + fileHandle := wfs.GetHandle(FileHandleId(out.Fh)) + if fileHandle == nil { + t.Fatal("Create did not register an open file handle") + } + if got := fileHandle.FullPath(); got != "/hello.txt" { + t.Fatalf("FullPath = %q, want %q", got, "/hello.txt") + } + + snapshot := testServer.snapshot() + if snapshot.directory != "/" { + t.Fatalf("CreateEntry directory = %q, want %q", snapshot.directory, "/") + } + if snapshot.name != "hello.txt" { + t.Fatalf("CreateEntry name = %q, want %q", snapshot.name, "hello.txt") + } + if snapshot.uid != 123 || snapshot.gid != 456 { + t.Fatalf("CreateEntry uid/gid = %d/%d, want 123/456", snapshot.uid, snapshot.gid) + } + if snapshot.mode != 0o640 { + t.Fatalf("CreateEntry mode = %o, want %o", snapshot.mode, 0o640) + } +} + +func TestTruncateEntryClearsDirtyPagesForOpenHandle(t *testing.T) { + wfs, _ := newCreateTestWFS(t) + + fullPath := util.FullPath("/truncate.txt") + inode := wfs.inodeToPath.Lookup(fullPath, 1, false, false, 0, true) + entry := &filer_pb.Entry{ + Name: "truncate.txt", + Attributes: &filer_pb.FuseAttributes{ + FileMode: 0o644, + FileSize: 5, + Inode: inode, + Crtime: 1, + Mtime: 1, + }, + } + + fh := wfs.fhMap.AcquireFileHandle(wfs, inode, entry) + fh.RememberPath(fullPath) + + if err := fh.dirtyPages.AddPage(0, []byte("hello"), true, time.Now().UnixNano()); err != nil { + t.Fatalf("AddPage: %v", err) + } + oldDirtyPages := fh.dirtyPages + + truncatedEntry := &filer_pb.Entry{ + Name: "truncate.txt", + Attributes: &filer_pb.FuseAttributes{ + FileMode: 0o644, + FileSize: 5, + Inode: inode, + Crtime: 1, + Mtime: 1, + }, + } + + if status := wfs.truncateEntry(fullPath, truncatedEntry); status != fuse.OK { + t.Fatalf("truncateEntry status = %v, want OK", status) + } + if fh.dirtyPages == oldDirtyPages { + t.Fatal("truncateEntry should replace the dirtyPages writer for an open handle") + } + if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 0 { + t.Fatalf("file handle size = %d, want 0", got) + } + buf := make([]byte, 5) + if maxStop := fh.dirtyPages.ReadDirtyDataAt(buf, 0, time.Now().UnixNano()); maxStop != 0 { + t.Fatalf("dirty pages maxStop = %d, want 0 after truncate", maxStop) + } +} + +func TestAccessChecksPermissions(t *testing.T) { + wfs := newCopyRangeTestWFS() + oldLookupSupplementaryGroupIDs := lookupSupplementaryGroupIDs + lookupSupplementaryGroupIDs = func(uint32) ([]string, error) { + return nil, nil + } + t.Cleanup(func() { + lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs + }) + + fullPath := util.FullPath("/visible.txt") + inode := wfs.inodeToPath.Lookup(fullPath, 1, false, false, 0, true) + handle := wfs.fhMap.AcquireFileHandle(wfs, inode, &filer_pb.Entry{ + Name: "visible.txt", + Attributes: &filer_pb.FuseAttributes{ + FileMode: 0o640, + Uid: 123, + Gid: 456, + Inode: inode, + }, + }) + handle.RememberPath(fullPath) + + if status := wfs.Access(make(chan struct{}), &fuse.AccessIn{ + InHeader: fuse.InHeader{ + NodeId: inode, + Caller: fuse.Caller{ + Owner: fuse.Owner{ + Uid: 123, + Gid: 999, + }, + }, + }, + Mask: fuse.R_OK | fuse.W_OK, + }); status != fuse.OK { + t.Fatalf("owner Access status = %v, want OK", status) + } + + if status := wfs.Access(make(chan struct{}), &fuse.AccessIn{ + InHeader: fuse.InHeader{ + NodeId: inode, + Caller: fuse.Caller{ + Owner: fuse.Owner{ + Uid: 999, + Gid: 999, + }, + }, + }, + Mask: fuse.W_OK, + }); status != fuse.EACCES { + t.Fatalf("other-user Access status = %v, want EACCES", status) + } + + if got := hasAccess(123, 999, 123, 456, 0o400, fuse.R_OK|fuse.W_OK); got { + t.Fatal("owner should not get write access from a read-only owner mode") + } + + if got := hasAccess(999, 456, 123, 456, 0o040, fuse.R_OK|fuse.W_OK); got { + t.Fatal("group member should not get write access from a read-only group mode") + } + + if got := hasAccess(999, 999, 123, 456, 0o004, fuse.R_OK|fuse.W_OK); got { + t.Fatal("other users should not get write access from a read-only other mode") + } + + if got := hasAccess(0, 0, 123, 456, 0o644, fuse.X_OK); got { + t.Fatal("root should not get execute access when no execute bit is set") + } + + if got := hasAccess(0, 0, 123, 456, 0o755, fuse.R_OK|fuse.X_OK); !got { + t.Fatal("root should get execute access when at least one execute bit is set") + } +} + +func TestHasAccessUsesSupplementaryGroups(t *testing.T) { + oldLookupSupplementaryGroupIDs := lookupSupplementaryGroupIDs + lookupSupplementaryGroupIDs = func(uint32) ([]string, error) { + return []string{"456"}, nil + } + t.Cleanup(func() { + lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs + }) + + if got := hasAccess(999, 999, 123, 456, 0o060, fuse.R_OK|fuse.W_OK); !got { + t.Fatal("supplementary group membership should grant matching group permissions") + } +} + +func TestCreateExistingFileIgnoresQuotaPreflight(t *testing.T) { + wfs, _ := newCreateTestWFS(t) + wfs.option.Quota = 1 + wfs.IsOverQuota = true + + entry := &filer_pb.Entry{ + Name: "existing.txt", + Attributes: &filer_pb.FuseAttributes{ + FileMode: 0o644, + FileSize: 7, + Inode: 101, + Crtime: 1, + Mtime: 1, + Uid: 123, + Gid: 456, + }, + } + if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/", entry)); err != nil { + t.Fatalf("InsertEntry: %v", err) + } + wfs.inodeToPath.Lookup(util.FullPath("/existing.txt"), entry.Attributes.Crtime, false, false, entry.Attributes.Inode, true) + + out := &fuse.CreateOut{} + status := wfs.Create(make(chan struct{}), &fuse.CreateIn{ + InHeader: fuse.InHeader{ + NodeId: 1, + Caller: fuse.Caller{ + Owner: fuse.Owner{ + Uid: 123, + Gid: 456, + }, + }, + }, + Flags: syscall.O_WRONLY | syscall.O_CREAT | syscall.O_EXCL, + Mode: 0o644, + }, "existing.txt", out) + if status != fuse.Status(syscall.EEXIST) { + t.Fatalf("Create status = %v, want EEXIST", status) + } +} diff --git a/weed/mount/weedfs_filehandle.go b/weed/mount/weedfs_filehandle.go index 47700eb1e..155a97cd8 100644 --- a/weed/mount/weedfs_filehandle.go +++ b/weed/mount/weedfs_filehandle.go @@ -21,6 +21,12 @@ func (wfs *WFS) AcquireHandle(inode uint64, flags, uid, gid uint32) (fileHandle if wormEnforced, _ := wfs.wormEnforcedForEntry(path, entry); wormEnforced && flags&fuse.O_ANYWRITE != 0 { return nil, fuse.EPERM } + // Check unix permission bits for the requested access mode. + if entry != nil && entry.Attributes != nil { + if mask := openFlagsToAccessMask(flags); mask != 0 && !hasAccess(uid, gid, entry.Attributes.Uid, entry.Attributes.Gid, entry.Attributes.FileMode, mask) { + return nil, fuse.EACCES + } + } // need to AcquireFileHandle again to ensure correct handle counter fileHandle = wfs.fhMap.AcquireFileHandle(wfs, inode, entry) fileHandle.RememberPath(path) diff --git a/weed/mount/weedfs_unsupported.go b/weed/mount/weedfs_unsupported.go index eb9afbf04..7a8c0af37 100644 --- a/weed/mount/weedfs_unsupported.go +++ b/weed/mount/weedfs_unsupported.go @@ -15,16 +15,3 @@ import "github.com/seaweedfs/go-fuse/v2/fuse" func (wfs *WFS) Fallocate(cancel <-chan struct{}, in *fuse.FallocateIn) (code fuse.Status) { return fuse.ENOSYS } - -/** - * Check file access permissions - * - * This will be called for the access() system call. If the - * 'default_permissions' mount option is given, this method is not - * called. - * - * This method is not called under Linux kernel versions 2.4.x - */ -func (wfs *WFS) Access(cancel <-chan struct{}, input *fuse.AccessIn) (code fuse.Status) { - return fuse.ENOSYS -}