mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-29 08:20:42 +01:00
* sftp: refuse mkdir and uploads under a missing folder * sftp: re-check the parent directory when a buffered upload commits The open-time check cannot catch a parent removed while the upload buffered; the filer would recreate it when the entry lands. * sftp: cover mkdir and upload commit against a missing parent Extend the test filer with CreateEntry so makeDir can be exercised end to end. * sftp: refuse a rename into a missing folder The filer's rename path creates the target entry through CreateEntry, which recreates absent parents the same way uploads did. --------- Co-authored-by: user.email <chrislusf@users.noreply.github.com>
633 lines
27 KiB
Go
633 lines
27 KiB
Go
package sftpd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/pkg/sftp"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
func TestPathWithin(t *testing.T) {
|
|
tests := []struct {
|
|
base string
|
|
candidate string
|
|
want bool
|
|
}{
|
|
{"/tenants/alice", "/tenants/alice", true},
|
|
{"/tenants/alice", "/tenants/alice/file.txt", true},
|
|
{"/tenants/alice", "/tenants/alice/sub/dir", true},
|
|
{"/tenants/alice", "/tenants/alice-archive", false},
|
|
{"/tenants/alice", "/tenants/alice-archive/secret.txt", false},
|
|
{"/tenants/alice", "/tenants/alice2", false},
|
|
{"/tenants/alice", "/tenants", false},
|
|
{"/tenants/alice/", "/tenants/alice/file.txt", true},
|
|
{"/tenants/alice", "/tenants/alice/../alice-archive", false},
|
|
{"/tenants/alice", "/tenants/alice/..", false},
|
|
{"/", "/", true},
|
|
{"/", "/anything", true},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := pathWithin(tt.base, tt.candidate); got != tt.want {
|
|
t.Errorf("pathWithin(%q, %q) = %v, want %v", tt.base, tt.candidate, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHasExplicitPermissionSiblingPrefix(t *testing.T) {
|
|
u := &user.User{
|
|
Username: "scoped",
|
|
HomeDir: "/",
|
|
Permissions: map[string][]string{
|
|
"/tenants/alice": {"read", "write", "list"},
|
|
},
|
|
}
|
|
|
|
if !HasExplicitPermission(u, "/tenants/alice/file.txt", PermRead, false) {
|
|
t.Error("expected read permission under /tenants/alice")
|
|
}
|
|
if HasExplicitPermission(u, "/tenants/alice-archive/secret.txt", PermRead, false) {
|
|
t.Error("ACL for /tenants/alice must not grant read on sibling /tenants/alice-archive")
|
|
}
|
|
if HasExplicitPermission(u, "/tenants/alice-archive/secret.txt", PermWrite, false) {
|
|
t.Error("ACL for /tenants/alice must not grant write on sibling /tenants/alice-archive")
|
|
}
|
|
}
|
|
|
|
func TestIsPathInHomeDirectorySiblingPrefix(t *testing.T) {
|
|
u := &user.User{Username: "alice", HomeDir: "/tenants/alice"}
|
|
|
|
if !isPathInHomeDirectory(u, "/tenants/alice/file.txt") {
|
|
t.Error("expected /tenants/alice/file.txt to be within home /tenants/alice")
|
|
}
|
|
if isPathInHomeDirectory(u, "/tenants/alice-archive/secret.txt") {
|
|
t.Error("home /tenants/alice must not match sibling /tenants/alice-archive")
|
|
}
|
|
}
|
|
|
|
// newFileWriter must honor a more specific ACL on the target file, not just
|
|
// the parent directory's: a file the user cannot write stays read-only even
|
|
// in a writable directory. Missing paths still defer to the parent directory.
|
|
func TestNewFileWriterChecksTargetFile(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
|
"/": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: 0755}},
|
|
"/home": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: 0755}},
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/locked.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
"/home/alice/ok.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
"/home/alice/nested": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/nested/ok.f": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"readwrite"},
|
|
"/home/alice/locked.txt": {"read"},
|
|
},
|
|
},
|
|
}
|
|
|
|
write := func(path string) error {
|
|
w, err := fs.newFileWriter(&sftp.Request{Filepath: path})
|
|
if writer, ok := w.(*SeaweedSftpFileWriter); ok {
|
|
_ = os.Remove(writer.tmpFile.Name())
|
|
_ = writer.tmpFile.Close()
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := write("/locked.txt"); err == nil {
|
|
t.Error("expected write denial on a file with an explicit read-only ACL")
|
|
}
|
|
if err := write("/ok.txt"); err != nil {
|
|
t.Errorf("expected write under the directory ACL, got %v", err)
|
|
}
|
|
if err := write("/new.txt"); err != nil {
|
|
t.Errorf("expected write on a new file in a writable dir, got %v", err)
|
|
}
|
|
if err := write("/nested/new.txt"); err != nil {
|
|
t.Errorf("expected write on a new file under a missing intermediate check, got %v", err)
|
|
}
|
|
delete(filer.entries, "/home/alice/nested")
|
|
delete(filer.entries, "/home/alice/nested/ok.f")
|
|
if err := fs.checkFilePermission("/home/alice/gone/deeper.txt", "write"); err != nil {
|
|
t.Errorf("expected write when ancestors resolve to the writable dir, got %v", err)
|
|
}
|
|
// The missing folder itself is not created on the way.
|
|
if err := write("/gone/deeper.txt"); err != os.ErrNotExist {
|
|
t.Errorf("expected os.ErrNotExist under a missing folder, got %v", err)
|
|
}
|
|
}
|
|
|
|
// makeDir and a committed upload must refuse a parent that is missing, and a
|
|
// parent removed while an upload buffered must fail the close too.
|
|
func TestRequireParentDirEnforced(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
|
"/": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: 0755}},
|
|
"/home": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: 0755}},
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/d": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/a.t": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"*"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := fs.makeDir("/home/alice/missing/new"); err != os.ErrNotExist {
|
|
t.Fatalf("mkdir under a missing parent: got %v, want os.ErrNotExist", err)
|
|
}
|
|
if _, ok := filer.entries["/home/alice/missing"]; ok {
|
|
t.Error("mkdir created the missing parent")
|
|
}
|
|
if _, ok := filer.entries["/home/alice/missing/new"]; ok {
|
|
t.Error("mkdir created a directory under a missing parent")
|
|
}
|
|
|
|
if err := fs.renameEntry("/home/alice/a.t", "/home/alice/missing/b.t"); err != os.ErrNotExist {
|
|
t.Fatalf("rename into a missing folder: got %v, want os.ErrNotExist", err)
|
|
}
|
|
if _, ok := filer.entries["/home/alice/missing/b.t"]; ok {
|
|
t.Error("rename moved a file into a missing folder")
|
|
}
|
|
|
|
if err := fs.makeDir("/home/alice/d/new"); err != nil {
|
|
t.Fatalf("mkdir under an existing parent: %v", err)
|
|
}
|
|
if _, ok := filer.entries["/home/alice/d/new"]; !ok {
|
|
t.Error("mkdir under an existing parent did not create the entry")
|
|
}
|
|
|
|
w, err := fs.newFileWriter(&sftp.Request{Filepath: "/d/f.txt"})
|
|
if err != nil {
|
|
t.Fatalf("open upload under an existing parent: %v", err)
|
|
}
|
|
writer, ok := w.(*SeaweedSftpFileWriter)
|
|
if !ok {
|
|
t.Fatalf("unexpected writer type %T", w)
|
|
}
|
|
delete(filer.entries, "/home/alice/d")
|
|
if err := writer.Close(); err != os.ErrNotExist {
|
|
t.Errorf("upload closing after its parent was removed: got %v, want os.ErrNotExist", err)
|
|
}
|
|
}
|
|
|
|
// renameEntry must honor a more specific ACL on an existing target: renaming
|
|
// over a read-only file deletes it, so it requires delete permission on the
|
|
// target file itself, not only write permission on the target directory.
|
|
func TestRenameEntryChecksTargetFile(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/own.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
"/home/alice/ok.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
"/home/alice/locked.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"read", "write", "list", "delete", "rename"},
|
|
"/home/alice/locked.txt": {"read", "list"},
|
|
},
|
|
},
|
|
}
|
|
|
|
renameDenied := func(src, dst string) bool {
|
|
return errors.Is(fs.renameEntry(src, dst), os.ErrPermission)
|
|
}
|
|
|
|
if !renameDenied("/home/alice/own.txt", "/home/alice/locked.txt") {
|
|
t.Error("expected rename over a read-only file to be denied")
|
|
}
|
|
if err := fs.renameEntry("/home/alice/own.txt", "/home/alice/ok.txt"); err != nil {
|
|
t.Errorf("expected rename over a writable file to succeed, got %v", err)
|
|
}
|
|
if err := fs.renameEntry("/home/alice/ok.txt", "/home/alice/new.txt"); err != nil {
|
|
t.Errorf("expected rename onto a new path in a writable dir to succeed, got %v", err)
|
|
}
|
|
if err := fs.renameEntry("/home/alice/new.txt", "/home/alice/new.txt"); err != nil {
|
|
t.Errorf("expected a no-op rename to skip the target delete check, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Renaming a directory must not strand a more specific ACL configured under
|
|
// it: after /sub moves to /sub2, /sub/ro.txt no longer matches its read-only
|
|
// ACL, so the move is denied unless the destination grants no new access.
|
|
func TestRenameEntryKeepsNestedAcl(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/sub": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/sub/ro.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"read", "write", "list", "delete", "rename"},
|
|
"/home/alice/sub/ro.txt": {"read", "list"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := fs.renameEntry("/home/alice/sub", "/home/alice/sub2"); !errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected rename that strands the /sub/ro.txt ACL to be denied, got %v", err)
|
|
}
|
|
delete(fs.user.Permissions, "/home/alice/sub/ro.txt")
|
|
if err := fs.renameEntry("/home/alice/sub", "/home/alice/sub2"); err != nil {
|
|
t.Errorf("expected rename without a nested carve-out to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckFilePermissionMissingAncestorsTerminates(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{Username: "alice", Uid: 1000, HomeDir: "/home/alice"},
|
|
}
|
|
if err := fs.checkFilePermission("/a/b/c", "write"); err == nil {
|
|
t.Error("expected denial when no ancestor exists")
|
|
}
|
|
}
|
|
|
|
type permissionTestFiler struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
entries map[string]*filer_pb.Entry
|
|
lookupErr map[string]error
|
|
// createBeforeRename inserts an entry at the rename target inside the RPC,
|
|
// simulating a file a concurrent connection creates after the checks ran.
|
|
createBeforeRename map[string]*filer_pb.Entry
|
|
}
|
|
|
|
func (f *permissionTestFiler) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
key := req.Directory + "/" + req.Name
|
|
if err, ok := f.lookupErr[key]; ok {
|
|
return nil, err
|
|
}
|
|
if entry, ok := f.entries[key]; ok {
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
|
|
}
|
|
return nil, status.Error(codes.NotFound, "no entry is found")
|
|
}
|
|
|
|
func (f *permissionTestFiler) AtomicRenameEntry(_ context.Context, req *filer_pb.AtomicRenameEntryRequest) (*filer_pb.AtomicRenameEntryResponse, error) {
|
|
oldKey := req.OldDirectory + "/" + req.OldName
|
|
entry, ok := f.entries[oldKey]
|
|
if !ok {
|
|
return nil, status.Error(codes.NotFound, "no entry is found")
|
|
}
|
|
newKey := req.NewDirectory + "/" + req.NewName
|
|
if created, ok := f.createBeforeRename[newKey]; ok {
|
|
f.entries[newKey] = created
|
|
}
|
|
if req.FailIfTargetExists {
|
|
if _, exists := f.entries[newKey]; exists && newKey != oldKey {
|
|
return nil, status.Error(codes.AlreadyExists, "entry already exists")
|
|
}
|
|
}
|
|
delete(f.entries, oldKey)
|
|
f.entries[newKey] = entry
|
|
return &filer_pb.AtomicRenameEntryResponse{}, nil
|
|
}
|
|
|
|
func (f *permissionTestFiler) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
|
f.entries[req.Directory+"/"+req.Entry.Name] = req.Entry
|
|
return &filer_pb.CreateEntryResponse{}, nil
|
|
}
|
|
|
|
// checkRenameKeepsAcl also compares the moved root (inherited restrictions) and
|
|
// ACL paths configured under the destination (deeper grants), while configured
|
|
// paths without a real entry do not block the rename.
|
|
func TestRenameEntryKeepsInheritedAndDestinationAcl(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/restricted": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/restricted/sub": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/sub": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/sub/locked": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/sub/locked/secret": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
"/home/alice/sub3": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"read", "write", "list", "delete", "rename"},
|
|
"/home/alice/restricted": {"read", "list", "rename"},
|
|
"/home/alice/sub/locked": {"read", "list"},
|
|
"/home/alice/sub2/locked": {"read", "list"},
|
|
"/home/alice/sub2/locked/secret": {"read", "write", "list", "delete"},
|
|
"/home/alice/sub3/ghost.txt": {"read", "list"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := fs.renameEntry("/home/alice/restricted/sub", "/home/alice/sub5"); !errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected rename escaping the inherited restricted ACL to be denied, got %v", err)
|
|
}
|
|
if err := fs.renameEntry("/home/alice/sub", "/home/alice/sub2"); !errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected rename into a deeper destination grant to be denied, got %v", err)
|
|
}
|
|
if err := fs.renameEntry("/home/alice/sub3", "/home/alice/sub4"); err != nil {
|
|
t.Errorf("expected rename with a missing reserved ACL child to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// A rename onto a name that does not exist needs no delete access unless a
|
|
// configured ACL restricts that name itself. Without the scoped check a user
|
|
// holding write+rename but not delete could no longer move files to new names.
|
|
func TestRenameEntryNewNameNeedsNoDelete(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/own.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
"/home/alice/existing.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
"/home/alice/removable.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"read", "write", "list", "rename"},
|
|
"/home/alice/reserved.txt": {"read", "list"},
|
|
"/home/alice/managed.txt": {"read", "write", "list", "delete"},
|
|
"/home/alice/removable.txt": {"read", "write", "list", "delete", "rename"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := fs.renameEntry("/home/alice/own.txt", "/home/alice/new.txt"); err != nil {
|
|
t.Errorf("expected rename to a new name without delete access to succeed, got %v", err)
|
|
}
|
|
if err := fs.renameEntry("/home/alice/new.txt", "/home/alice/existing.txt"); !errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected rename over an existing file without delete access to be denied, got %v", err)
|
|
}
|
|
if err := fs.renameEntry("/home/alice/new.txt", "/home/alice/reserved.txt"); !errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected rename onto a delete-protected configured name to be denied, got %v", err)
|
|
}
|
|
if err := fs.renameEntry("/home/alice/removable.txt", "/home/alice/managed.txt"); err != nil {
|
|
t.Errorf("expected rename onto a configured name that grants delete to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// checkRenameKeepsAcl fails closed: a lookup error on a configured ACL path is
|
|
// not proof that the entry is missing, so it must stop the rename instead of
|
|
// skipping the comparison.
|
|
func TestRenameEntryFailsClosedOnLookupError(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{
|
|
entries: map[string]*filer_pb.Entry{
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/sub": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/sub/ro.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
},
|
|
lookupErr: map[string]error{
|
|
"/home/alice/sub/ro.txt": status.Error(codes.Unavailable, "filer unavailable"),
|
|
},
|
|
}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"read", "write", "list", "delete", "rename"},
|
|
"/home/alice/sub/ro.txt": {"read", "list"},
|
|
},
|
|
},
|
|
}
|
|
|
|
err = fs.renameEntry("/home/alice/sub", "/home/alice/sub2")
|
|
if err == nil || errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected rename to fail closed on a lookup error, got %v", err)
|
|
}
|
|
delete(filer.lookupErr, "/home/alice/sub/ro.txt")
|
|
if err := fs.renameEntry("/home/alice/sub", "/home/alice/sub2"); !errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected the stranded ACL check to deny the rename once the lookup succeeds, got %v", err)
|
|
}
|
|
}
|
|
|
|
// A rename must not grant access the source side lacked, including read and
|
|
// directory listing: moving a subtree whose ACL withholds read/list into a
|
|
// readable destination would expose its contents.
|
|
func TestRenameEntryKeepsReadAndListAcl(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/noread": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/noread/f.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"read", "write", "list", "delete", "rename"},
|
|
"/home/alice/noread": {"write", "delete", "rename"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := fs.renameEntry("/home/alice/noread", "/home/alice/visible"); !errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected rename that grants read/list on the moved directory to be denied, got %v", err)
|
|
}
|
|
}
|
|
|
|
// A moved directory carries file children whose ACLs resolve with
|
|
// isDirectory=false, where list does not count as read. Moving a directory
|
|
// that grants list but not read into a readable destination must be denied:
|
|
// its file children would become readable even though the directory check
|
|
// passes.
|
|
func TestRenameEntryKeepsFileChildAcl(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/private": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/private/secret.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
}}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"read", "write", "list", "delete", "rename"},
|
|
"/home/alice/private": {"list", "write", "delete", "rename"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := fs.renameEntry("/home/alice/private", "/home/alice/public"); !errors.Is(err, os.ErrPermission) {
|
|
t.Errorf("expected rename that grants read on file children to be denied, got %v", err)
|
|
}
|
|
fs.user.Permissions["/home/alice/public"] = []string{"list", "write", "delete", "rename"}
|
|
if err := fs.renameEntry("/home/alice/private", "/home/alice/public"); err != nil {
|
|
t.Errorf("expected rename onto an equally restricted ACL to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// A rename onto a new name needs no delete access, but a target created in the
|
|
// gap between the check and the move must not be silently overwritten: the
|
|
// filer is asked to fail the rename when the target appears.
|
|
func TestRenameEntryRejectsConcurrentTarget(t *testing.T) {
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
filer := &permissionTestFiler{
|
|
entries: map[string]*filer_pb.Entry{
|
|
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
|
|
"/home/alice/own.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
},
|
|
createBeforeRename: map[string]*filer_pb.Entry{
|
|
"/home/alice/taken.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
|
|
},
|
|
}
|
|
grpcServer := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
|
|
go func() { _ = grpcServer.Serve(lis) }()
|
|
t.Cleanup(grpcServer.Stop)
|
|
|
|
_, port, _ := net.SplitHostPort(lis.Addr().String())
|
|
fs := &SftpServer{
|
|
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
|
|
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
user: &user.User{
|
|
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
|
|
Permissions: map[string][]string{
|
|
"/home/alice": {"read", "write", "list", "rename"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := fs.renameEntry("/home/alice/own.txt", "/home/alice/free.txt"); err != nil {
|
|
t.Errorf("expected rename to a name that stays absent to succeed, got %v", err)
|
|
}
|
|
if err := fs.renameEntry("/home/alice/free.txt", "/home/alice/taken.txt"); err == nil {
|
|
t.Error("expected rename to fail when the target appears concurrently and the user lacks delete access")
|
|
}
|
|
}
|