Files
seaweedfs/weed/sftpd/sftp_filer.go
T
Chris LuandDevin 8d9c7e76a5 sftp: enforce file ACLs on rename targets (#11770)
* sftp: check delete permission on an existing rename target

renameEntry authorized only the source path and the target directory,
so renaming over a file with a more restrictive ACL destroyed it even
though Remove on the same file was denied.

* sftp: deny a rename that would strand a carved-out ACL

A rename moves data without moving its path-based ACL: renaming an
ancestor directory left a more specific read-only entry behind, so the
relocated file fell under a permissive ancestor ACL and became
writable. Deny the rename when a configured ACL under the source would
grant less write/delete access than the relocated path would get.

* sftp: cover inherited and destination-side ACLs on rename

The rename ACL check compared only configured paths under the source,
so it missed restrictions inherited from ancestors above the source and
deeper grants configured under the destination. It now compares the
moved root plus every configured path under either tree mapped to its
source counterpart, and skips paths whose entries do not actually move.
The target delete check no longer depends on an existence lookup, so a
protected target created during a rename cannot be overwritten; a no-op
rename skips it. Root bypasses the comparison like every other check.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* sftp: fail closed on rename ACL lookups and restore new-name renames

- checkRenameKeepsAcl returns lookup errors instead of treating them as a missing entry, so a transient filer failure cannot skip the ACL comparison; it also compares read and list gains, not only write and delete, and evaluates each pair with the moved entry's own IsDirectory.

- The target delete check now distinguishes an existing target (still requires delete) from a fresh name (no delete needed) while still enforcing a configured ACL on the target name itself, so a concurrent create cannot slip a protected target under the rename.

* filer: add fail_if_target_exists option to AtomicRenameEntry

Callers that authorize a rename while the target is absent cannot see a
target created in the gap before the move runs. The flag makes the filer
reject the rename when the target exists, checked under the rename path
locks that CreateEntry on the same filer also takes.

* sftp: guard rename against concurrent targets and file-child ACL gains

A rename onto a name that inherits its directory's ACL still needs no
delete access, but now asks the filer to fail if a target appears before
the move, so a concurrently created file cannot be overwritten without
delete permission. Moved directories are also compared in the file ACL
shape, where list no longer counts as read, so their file children cannot
gain read access through the relocation.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 12:36:20 -07:00

680 lines
21 KiB
Go

// sftp_filer_refactored.go
package sftpd
import (
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"github.com/seaweedfs/seaweedfs/weed/util"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc"
)
const (
defaultTimeout = 30 * time.Second
defaultListLimit = 1000
)
// ==================== Filer RPC Helpers ====================
// callWithClient wraps a gRPC client call with timeout and client creation.
func (fs *SftpServer) callWithClient(streaming bool, fn func(ctx context.Context, client filer_pb.SeaweedFilerClient) error) error {
return fs.withTimeoutContext(func(ctx context.Context) error {
return fs.WithFilerClient(streaming, func(client filer_pb.SeaweedFilerClient) error {
return fn(ctx, client)
})
})
}
// getEntry retrieves a single directory entry by path.
func (fs *SftpServer) getEntry(p string) (*filer_pb.Entry, error) {
dir, name := util.FullPath(p).DirAndName()
var entry *filer_pb.Entry
err := fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
r, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{Directory: dir, Name: name})
if err != nil {
if isNotExistError(err) {
return os.ErrNotExist
}
return err
}
if r.Entry == nil {
return fmt.Errorf("%s not found in %s", name, dir)
}
entry = r.Entry
return nil
})
if err != nil {
if isNotExistError(err) {
return nil, os.ErrNotExist
}
return nil, fmt.Errorf("lookup %s: %w", p, err)
}
return entry, nil
}
func isNotExistError(err error) bool {
return strings.Contains(err.Error(), "not found") ||
strings.Contains(err.Error(), "no entry is found") ||
strings.Contains(err.Error(), "file does not exist") ||
err == os.ErrNotExist
}
// updateEntry sends an UpdateEntryRequest for the given entry.
func (fs *SftpServer) updateEntry(dir string, entry *filer_pb.Entry) error {
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
_, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{Directory: dir, Entry: entry})
return err
})
}
// ==================== FilerClient Interface ====================
func (fs *SftpServer) AdjustedUrl(location *filer_pb.Location) string { return location.Url }
func (fs *SftpServer) GetDataCenter() string { return fs.dataCenter }
func (fs *SftpServer) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
addr := fs.filerAddr.ToGrpcAddress()
return pb.WithGrpcClient(context.Background(), streamingMode, util.RandomInt32(), func(conn *grpc.ClientConn) error {
return fn(filer_pb.NewSeaweedFilerClient(conn))
}, addr, false, fs.grpcDialOption)
}
func (fs *SftpServer) withTimeoutContext(fn func(ctx context.Context) error) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
return fn(ctx)
}
// ==================== Command Dispatcher ====================
func (fs *SftpServer) dispatchCmd(r *sftp.Request) error {
absPath, err := fs.toAbsolutePath(r.Filepath)
if err != nil {
return err
}
glog.V(1).Infof("Dispatch: %s %s (absolute: %s)", r.Method, r.Filepath, absPath)
switch r.Method {
case "Remove":
return fs.removeEntry(absPath)
case "Rename":
absTarget, err := fs.toAbsolutePath(r.Target)
if err != nil {
return err
}
return fs.renameEntry(absPath, absTarget)
case "Mkdir":
return fs.makeDir(absPath)
case "Rmdir":
return fs.removeDir(absPath)
case "Setstat":
return fs.setFileStatWithRequest(absPath, r)
default:
return fmt.Errorf("unsupported: %s", r.Method)
}
}
// ==================== File Operations ====================
func (fs *SftpServer) readFile(r *sftp.Request) (io.ReaderAt, error) {
absPath, err := fs.toAbsolutePath(r.Filepath)
if err != nil {
return nil, err
}
if err := fs.checkFilePermission(absPath, "read"); err != nil {
return nil, err
}
entry, err := fs.getEntry(absPath)
if err != nil {
return nil, err
}
return NewSeaweedFileReaderAt(fs, entry), nil
}
func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
absPath, err := fs.toAbsolutePath(r.Filepath)
if err != nil {
return nil, err
}
if err := fs.checkFilePermission(absPath, "write"); err != nil {
glog.Errorf("Permission denied for %s", absPath)
return nil, err
}
// Create a temporary file to buffer writes
tmpFile, err := os.CreateTemp("", "sftp-upload-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
// Without O_TRUNC the client writes into the existing file, so start from its current bytes.
if !r.Pflags().Trunc {
if err := fs.copyExistingContent(absPath, tmpFile); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
return nil, err
}
}
return &SeaweedSftpFileWriter{
fs: *fs,
req: r,
absPath: absPath,
tmpFile: tmpFile,
permissions: 0644,
uid: fs.user.Uid,
gid: fs.user.Gid,
offset: 0,
}, nil
}
func (fs *SftpServer) copyExistingContent(absPath string, dst io.Writer) error {
entry, err := fs.getEntry(absPath)
if err == os.ErrNotExist {
return nil
}
if err != nil {
return err
}
if entry.IsDirectory {
return nil
}
size := int64(filer.FileSize(entry))
remote := entry.GetRemoteEntry()
if len(entry.Content) == 0 && len(entry.GetChunks()) == 0 {
if remote != nil && remote.RemoteSize > 0 {
return fmt.Errorf("existing content of %s is not readable", absPath)
}
if size == 0 {
return nil
}
return writeZeros(dst, size)
}
if size == 0 {
return nil
}
if len(entry.Content) > 0 {
if _, err := dst.Write(entry.Content); err != nil {
return err
}
if remaining := size - int64(len(entry.Content)); remaining > 0 {
return writeZeros(dst, remaining)
}
return nil
}
streamFn, err := filer.PrepareStreamContent(lookupFileIdFn(filer.LookupFn(fs)), filer.JwtForVolumeServer, entry.GetChunks(), 0, size)
if err != nil {
return fmt.Errorf("read existing content of %s: %w", absPath, err)
}
if err := streamFn(dst); err != nil {
return fmt.Errorf("read existing content of %s: %w", absPath, err)
}
return nil
}
// lookupFileIdFn adapts a filer LookupFn to the chunk streamer's provider
// interface. PrepareStreamContent fills unstored gaps with zeros, so a sparse
// file's tail survives the copy.
type lookupFileIdFn wdclient.LookupFileIdFunctionType
func (fn lookupFileIdFn) GetLookupFileIdFunction() wdclient.LookupFileIdFunctionType {
return wdclient.LookupFileIdFunctionType(fn)
}
func writeZeros(w io.Writer, size int64) error {
zeros := make([]byte, 8192)
for size > 0 {
n := int64(len(zeros))
if size < n {
n = size
}
written, err := w.Write(zeros[:n])
size -= int64(written)
if err != nil {
return err
}
}
return nil
}
func (fs *SftpServer) removeEntry(absPath string) error {
return fs.deleteEntry(absPath, false)
}
func (fs *SftpServer) renameEntry(absPath, absTarget string) error {
if err := fs.checkFilePermission(absPath, "rename"); err != nil {
return err
}
targetDir, _ := util.FullPath(absTarget).DirAndName()
if err := fs.checkFilePermission(targetDir, "write"); err != nil {
return err
}
requireAbsentTarget := false
if path.Clean(absPath) != path.Clean(absTarget) {
var err error
requireAbsentTarget, err = fs.checkRenameTargetDelete(absTarget, targetDir)
if err != nil {
return err
}
}
if err := fs.checkRenameKeepsAcl(absPath, absTarget); err != nil {
return err
}
oldDir, oldName := util.FullPath(absPath).DirAndName()
newDir, newName := util.FullPath(absTarget).DirAndName()
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
_, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
OldDirectory: oldDir, OldName: oldName,
NewDirectory: newDir, NewName: newName,
// A target the user may not delete must stay absent: the filer
// checks it again under the rename locks, where a create cannot
// slip in.
FailIfTargetExists: requireAbsentTarget,
})
return err
})
}
// checkRenameTargetDelete enforces delete access where a rename can destroy an
// entry. Overwriting an existing target deletes it, so it needs delete access
// on the target itself. A name that does not exist yet needs no delete check
// unless a configured ACL restricts that name itself: enforcing such an ACL
// even while the entry is absent keeps a concurrent create from slipping a
// protected target under the rename. When the target name simply inherits its
// directory's ACL and the user could not delete an entry there, the rename is
// still allowed for a new name but the caller is told to require the target to
// stay absent, so the filer rejects the overwrite of one created in the gap.
// Lookup failures other than not-found fail closed.
func (fs *SftpServer) checkRenameTargetDelete(absTarget, targetDir string) (requireAbsentTarget bool, err error) {
if _, err := fs.getEntry(absTarget); err == nil {
return false, fs.checkFilePermission(absTarget, "delete")
} else if !errors.Is(err, os.ErrNotExist) {
return false, err
}
if fs.user == nil || fs.user.Username == "root" || fs.user.Uid == 0 {
return false, nil
}
if matchingAclPrefix(fs.user, absTarget) != matchingAclPrefix(fs.user, targetDir) {
if !HasExplicitPermission(fs.user, absTarget, PermDelete, false) {
glog.V(0).Infof("permission denied for user %s: rename target %s carries a delete-protected ACL", fs.user.Username, absTarget)
return false, os.ErrPermission
}
return false, nil
}
if err := fs.checkFilePermission(absTarget, "delete"); err != nil {
if errors.Is(err, os.ErrPermission) {
return true, nil
}
return false, err
}
return false, nil
}
// matchingAclPrefix returns the longest configured ACL path that applies to p,
// or "" when no configured path covers it.
func matchingAclPrefix(user *user.User, p string) string {
best := ""
for aclPath := range user.Permissions {
cleaned := path.Clean(aclPath)
if pathWithin(cleaned, p) && len(cleaned) > len(best) {
best = cleaned
}
}
return best
}
// checkRenameKeepsAcl denies a rename that would move entries to locations
// where ACLs grant the user read, list, write, or delete access the source
// side lacked. It compares the moved root and every configured ACL path under
// either the source or the target (mapped back to the source) so both
// inherited restrictions and deeper destination grants are caught.
func (fs *SftpServer) checkRenameKeepsAcl(absPath, absTarget string) error {
if fs.user == nil || fs.user.Username == "root" || fs.user.Uid == 0 {
return nil
}
type aclPair struct {
src, dst string
isDir bool
}
srcEntry, err := fs.getEntry(absPath)
if err != nil {
return err
}
pairs := []aclPair{{absPath, absTarget, srcEntry.IsDirectory}}
seen := map[string]bool{absPath: true}
for p := range fs.user.Permissions {
cleaned := path.Clean(p)
var src string
switch {
case pathWithin(absPath, cleaned):
src = cleaned
case pathWithin(absTarget, cleaned):
src = path.Join(absPath, strings.TrimPrefix(cleaned, absTarget))
default:
continue
}
if seen[src] {
continue
}
seen[src] = true
// A rule only protects an entry that actually moves; a path reserved
// for a missing child must not block the rename. A lookup error is not
// proof that the child is missing, so only os.ErrNotExist may skip.
entry, err := fs.getEntry(src)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return err
}
relocated := path.Join(absTarget, strings.TrimPrefix(src, absPath))
pairs = append(pairs, aclPair{src, relocated, entry.IsDirectory})
}
for _, pair := range pairs {
// A moved directory carries file children whose ACLs resolve with
// isDirectory=false, where list no longer counts as read. Comparing
// only the directory shape would miss children gaining read, so a
// directory is checked in the file shape too.
shapes := []bool{pair.isDir}
if pair.isDir {
shapes = append(shapes, false)
}
for _, isDir := range shapes {
for _, perm := range []string{PermRead, PermList, PermWrite, PermDelete} {
if !HasExplicitPermission(fs.user, pair.src, perm, isDir) && HasExplicitPermission(fs.user, pair.dst, perm, isDir) {
glog.V(0).Infof("permission denied for user %s: rename of %s to %s would drop the ACL on %s", fs.user.Username, absPath, absTarget, pair.src)
return os.ErrPermission
}
}
}
}
return nil
}
func (fs *SftpServer) setFileStatWithRequest(absPath string, r *sftp.Request) error {
if err := fs.checkFilePermission(absPath, "write"); err != nil {
return err
}
entry, err := fs.getEntry(absPath)
if err != nil {
return err
}
dir, _ := util.FullPath(absPath).DirAndName()
// apply attrs
if r.AttrFlags().Permissions {
entry.Attributes.FileMode = uint32(r.Attributes().FileMode())
}
if r.AttrFlags().UidGid {
entry.Attributes.Uid = uint32(r.Attributes().UID)
entry.Attributes.Gid = uint32(r.Attributes().GID)
}
if r.AttrFlags().Acmodtime {
entry.Attributes.Mtime = int64(r.Attributes().Mtime)
}
if r.AttrFlags().Size {
entry.Attributes.FileSize = uint64(r.Attributes().Size)
}
return fs.updateEntry(dir, entry)
}
// ==================== Directory Operations ====================
func (fs *SftpServer) listDir(r *sftp.Request) (sftp.ListerAt, error) {
absPath, err := fs.toAbsolutePath(r.Filepath)
if err != nil {
return nil, err
}
if err := fs.checkFilePermission(absPath, "list"); err != nil {
return nil, err
}
if r.Method == "Stat" || r.Method == "Lstat" {
entry, err := fs.getEntry(absPath)
if err != nil {
return nil, err
}
fi := &EnhancedFileInfo{FileInfo: FileInfoFromEntry(entry), uid: entry.Attributes.Uid, gid: entry.Attributes.Gid}
return listerat([]os.FileInfo{fi}), nil
}
return fs.listAllPages(absPath)
}
func (fs *SftpServer) listAllPages(dirPath string) (sftp.ListerAt, error) {
var all []os.FileInfo
last := ""
for {
page, lastName, received, err := fs.fetchDirectoryPage(dirPath, last)
if err != nil {
return nil, err
}
all = append(all, page...)
if received < defaultListLimit {
break
}
last = lastName
}
return listerat(all), nil
}
// fetchDirectoryPage returns the entries the user may list, plus the last name and count the filer sent.
func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo, string, int, error) {
var list []os.FileInfo
lastName := ""
received := 0
err := fs.callWithClient(true, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{Directory: dirPath, StartFromFileName: start, Limit: defaultListLimit})
if err != nil {
return err
}
for {
r, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
if r.Entry == nil {
continue
}
received++
lastName = r.Entry.Name
p := path.Join(dirPath, r.Entry.Name)
if err := fs.checkFilePermission(p, "list"); err != nil {
continue
}
list = append(list, &EnhancedFileInfo{FileInfo: FileInfoFromEntry(r.Entry), uid: r.Entry.Attributes.Uid, gid: r.Entry.Attributes.Gid})
}
return nil
})
return list, lastName, received, err
}
// makeDir creates a new directory with proper permissions.
func (fs *SftpServer) makeDir(absPath string) error {
if fs.user == nil {
return fmt.Errorf("cannot create directory: no user info")
}
dir, name := util.FullPath(absPath).DirAndName()
if err := fs.checkFilePermission(dir, "write"); err != nil {
return err
}
// default mode and ownership
err := filer_pb.Mkdir(context.Background(), fs, string(dir), name, func(entry *filer_pb.Entry) {
mode := uint32(0755 | os.ModeDir)
// Defensive check: all paths should be under HomeDir after toAbsolutePath translation
if absPath == fs.user.HomeDir || strings.HasPrefix(absPath, fs.user.HomeDir+"/") {
mode = uint32(0700 | os.ModeDir)
}
entry.Attributes.FileMode = mode
entry.Attributes.Uid = fs.user.Uid
entry.Attributes.Gid = fs.user.Gid
now := time.Now().Unix()
entry.Attributes.Crtime = now
entry.Attributes.Mtime = now
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
entry.Extended["creator"] = []byte(fs.user.Username)
})
return err
}
// removeDir deletes a directory.
func (fs *SftpServer) removeDir(absPath string) error {
return fs.deleteEntry(absPath, false)
}
func (fs *SftpServer) putFile(filepath string, reader io.Reader, user *user.User) error {
dir, filename := util.FullPath(filepath).DirAndName()
// Escape the path so a "?" in the filename cannot inject filer query commands like cp.from/mv.from.
uploadUrl := (&url.URL{Scheme: "http", Host: fs.filerAddr.ToHttpAddress(), Path: filepath}).String()
// Let the global HTTP client normalize the scheme to https:// when TLS is configured
normalizedUrl, err := util_http.NormalizeUrl(uploadUrl)
if err != nil {
return fmt.Errorf("normalize upload url %q: %w", uploadUrl, err)
}
uploadUrl = normalizedUrl
// Compute MD5 while uploading
hash := md5.New()
body := io.TeeReader(reader, hash)
// We can skip ContentLength if unknown (chunked transfer encoding)
req, err := http.NewRequest(http.MethodPut, uploadUrl, body)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
// Add JWT authorization if filer signing key is configured
if len(fs.filerSigningKey) > 0 {
jwt := security.GenJwtForFilerServer(security.SigningKey(fs.filerSigningKey), fs.filerSigningExpiresAfter)
if jwt != "" {
req.Header.Set("Authorization", security.BearerPrefix+string(jwt))
}
}
resp, err := util_http.Do(req)
if err != nil {
return fmt.Errorf("upload to filer: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
}
var result weed_server.FilerPostResult
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
if result.Error != "" {
return fmt.Errorf("filer error: %s", result.Error)
}
// Update file ownership using the same pattern as other functions
if user != nil {
err := fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
// Look up the file to get its current entry
lookupResp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: filename,
})
if err != nil {
return fmt.Errorf("lookup file for attribute update: %w", err)
}
if lookupResp.Entry == nil {
return fmt.Errorf("file not found after upload: %s/%s", dir, filename)
}
// Update the entry with new uid/gid
entry := lookupResp.Entry
entry.Attributes.Uid = user.Uid
entry.Attributes.Gid = user.Gid
// Update the entry in the filer
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
})
return err
})
if err != nil {
// Log the error but don't fail the whole operation
glog.Errorf("Failed to update file ownership for %s: %v", filepath, err)
}
}
return nil
}
// ==================== Common Arguments Helpers ====================
func FileInfoFromEntry(e *filer_pb.Entry) FileInfo {
return FileInfo{name: e.Name, size: int64(e.Attributes.FileSize), mode: os.FileMode(e.Attributes.FileMode), modTime: time.Unix(e.Attributes.Mtime, 0), isDir: e.IsDirectory}
}
func (fs *SftpServer) deleteEntry(p string, recursive bool) error {
if err := fs.checkFilePermission(p, "delete"); err != nil {
return err
}
dir, name := util.FullPath(p).DirAndName()
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
r, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{Directory: dir, Name: name, IsDeleteData: true, IsRecursive: recursive})
if err != nil {
return err
}
if r.Error != "" {
return fmt.Errorf("%s", r.Error)
}
return nil
})
}
// ==================== Custom Types ====================
type EnhancedFileInfo struct {
FileInfo
uid uint32
gid uint32
}
// FileStat represents file statistics in a platform-independent way
type FileStat struct {
Uid uint32
Gid uint32
}
func (fi *EnhancedFileInfo) Sys() interface{} {
return &FileStat{Uid: fi.uid, Gid: fi.gid}
}
func (fi *EnhancedFileInfo) Owner() (uid, gid int) {
return int(fi.uid), int(fi.gid)
}
func (fs *SftpServer) checkFilePermission(filepath string, permissions string) error {
return fs.CheckFilePermission(filepath, permissions)
}