Browse Source
mount: implement create for rsync temp files (#8749)
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 <copilot@github.com>pull/8763/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 688 additions and 91 deletions
-
2go.mod
-
2go.sum
-
7weed/mount/filehandle.go
-
99weed/mount/weedfs_access.go
-
15weed/mount/weedfs_file_io.go
-
265weed/mount/weedfs_file_mkrm.go
-
370weed/mount/weedfs_file_mkrm_test.go
-
6weed/mount/weedfs_filehandle.go
-
13weed/mount/weedfs_unsupported.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 |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue