Explorar el Código

sftpd: add statvfs@openssh.com support

Nicola Murino hace 4 años
padre
commit
51f110bc7b

+ 3 - 3
cmd/portable.go

@@ -325,7 +325,7 @@ func parsePatternsFilesFilters() []dataprovider.PatternsFilter {
 	var patterns []dataprovider.PatternsFilter
 	for _, val := range portableAllowedPatterns {
 		p, exts := getPatternsFilterValues(strings.TrimSpace(val))
-		if len(p) > 0 {
+		if p != "" {
 			patterns = append(patterns, dataprovider.PatternsFilter{
 				Path:            path.Clean(p),
 				AllowedPatterns: exts,
@@ -335,7 +335,7 @@ func parsePatternsFilesFilters() []dataprovider.PatternsFilter {
 	}
 	for _, val := range portableDeniedPatterns {
 		p, exts := getPatternsFilterValues(strings.TrimSpace(val))
-		if len(p) > 0 {
+		if p != "" {
 			found := false
 			for index, e := range patterns {
 				if path.Clean(e.Path) == path.Clean(p) {
@@ -364,7 +364,7 @@ func getPatternsFilterValues(value string) (string, []string) {
 			exts := []string{}
 			for _, e := range strings.Split(dirExts[1], ",") {
 				cleanedExt := strings.TrimSpace(e)
-				if len(cleanedExt) > 0 {
+				if cleanedExt != "" {
 					exts = append(exts, cleanedExt)
 				}
 			}

+ 1 - 1
common/common.go

@@ -705,7 +705,7 @@ func (c ConnectionStatus) GetConnectionInfo() string {
 func (c ConnectionStatus) GetTransfersAsString() string {
 	result := ""
 	for _, t := range c.Transfers {
-		if len(result) > 0 {
+		if result != "" {
 			result += ". "
 		}
 		result += t.getConnectionTransferAsString()

+ 9 - 5
common/connection.go

@@ -683,7 +683,7 @@ func (c *BaseConnection) hasSpaceForRename(virtualSourcePath, virtualTargetPath
 		// rename between user root dir and a virtual folder included in user quota
 		return true
 	}
-	quotaResult := c.HasSpace(true, virtualTargetPath)
+	quotaResult := c.HasSpace(true, false, virtualTargetPath)
 	return c.hasSpaceForCrossRename(quotaResult, initialSize, fsSourcePath)
 }
 
@@ -774,7 +774,7 @@ func (c *BaseConnection) GetMaxWriteSize(quotaResult vfs.QuotaCheckResult, isRes
 }
 
 // HasSpace checks user's quota usage
-func (c *BaseConnection) HasSpace(checkFiles bool, requestPath string) vfs.QuotaCheckResult {
+func (c *BaseConnection) HasSpace(checkFiles, getUsage bool, requestPath string) vfs.QuotaCheckResult {
 	result := vfs.QuotaCheckResult{
 		HasSpace:     true,
 		AllowedSize:  0,
@@ -792,14 +792,14 @@ func (c *BaseConnection) HasSpace(checkFiles bool, requestPath string) vfs.Quota
 	var vfolder vfs.VirtualFolder
 	vfolder, err = c.User.GetVirtualFolderForPath(path.Dir(requestPath))
 	if err == nil && !vfolder.IsIncludedInUserQuota() {
-		if vfolder.HasNoQuotaRestrictions(checkFiles) {
+		if vfolder.HasNoQuotaRestrictions(checkFiles) && !getUsage {
 			return result
 		}
 		result.QuotaSize = vfolder.QuotaSize
 		result.QuotaFiles = vfolder.QuotaFiles
 		result.UsedFiles, result.UsedSize, err = dataprovider.GetUsedVirtualFolderQuota(vfolder.Name)
 	} else {
-		if c.User.HasNoQuotaRestrictions(checkFiles) {
+		if c.User.HasNoQuotaRestrictions(checkFiles) && !getUsage {
 			return result
 		}
 		result.QuotaSize = c.User.QuotaSize
@@ -981,9 +981,13 @@ func (c *BaseConnection) GetOpUnsupportedError() error {
 func (c *BaseConnection) GetGenericError(err error) error {
 	switch c.protocol {
 	case ProtocolSFTP:
+		if err == vfs.ErrStorageSizeUnavailable {
+			return sftp.ErrSSHFxOpUnsupported
+		}
 		return sftp.ErrSSHFxFailure
 	default:
-		if err == ErrPermissionDenied || err == ErrNotExist || err == ErrOpUnsupported || err == ErrQuotaExceeded {
+		if err == ErrPermissionDenied || err == ErrNotExist || err == ErrOpUnsupported ||
+			err == ErrQuotaExceeded || err == vfs.ErrStorageSizeUnavailable {
 			return err
 		}
 		return ErrGenericFailure

+ 12 - 6
common/connection_test.go

@@ -917,7 +917,7 @@ func TestHasSpaceForRename(t *testing.T) {
 	c := NewBaseConnection("", ProtocolSFTP, user, fs)
 	// with quota tracking disabled hasSpaceForRename will always return true
 	assert.True(t, c.hasSpaceForRename("", "", 0, ""))
-	quotaResult := c.HasSpace(true, "")
+	quotaResult := c.HasSpace(true, false, "")
 	assert.True(t, quotaResult.HasSpace)
 
 	err = closeDataprovider()
@@ -1028,7 +1028,7 @@ func TestHasSpace(t *testing.T) {
 	fs, err := user.GetFilesystem("id")
 	assert.NoError(t, err)
 	c := NewBaseConnection("", ProtocolSFTP, user, fs)
-	quotaResult := c.HasSpace(true, "/")
+	quotaResult := c.HasSpace(true, false, "/")
 	assert.True(t, quotaResult.HasSpace)
 
 	user.VirtualFolders[0].QuotaFiles = 0
@@ -1038,7 +1038,7 @@ func TestHasSpace(t *testing.T) {
 	user, err = dataprovider.UserExists(user.Username)
 	assert.NoError(t, err)
 	c.User = user
-	quotaResult = c.HasSpace(true, "/vdir/file")
+	quotaResult = c.HasSpace(true, false, "/vdir/file")
 	assert.True(t, quotaResult.HasSpace)
 
 	user.VirtualFolders[0].QuotaFiles = 10
@@ -1046,17 +1046,17 @@ func TestHasSpace(t *testing.T) {
 	err = dataprovider.UpdateUser(&user)
 	assert.NoError(t, err)
 	c.User = user
-	quotaResult = c.HasSpace(true, "/vdir/file1")
+	quotaResult = c.HasSpace(true, false, "/vdir/file1")
 	assert.True(t, quotaResult.HasSpace)
 
-	quotaResult = c.HasSpace(true, "/file")
+	quotaResult = c.HasSpace(true, false, "/file")
 	assert.True(t, quotaResult.HasSpace)
 
 	folder, err := dataprovider.GetFolderByName(folderName)
 	assert.NoError(t, err)
 	err = dataprovider.UpdateVirtualFolderQuota(folder, 10, 1048576, true)
 	assert.NoError(t, err)
-	quotaResult = c.HasSpace(true, "/vdir/file1")
+	quotaResult = c.HasSpace(true, false, "/vdir/file1")
 	assert.False(t, quotaResult.HasSpace)
 
 	err = dataprovider.DeleteUser(user.Username)
@@ -1199,6 +1199,12 @@ func TestErrorsMapping(t *testing.T) {
 		} else {
 			assert.EqualError(t, err, ErrOpUnsupported.Error())
 		}
+		err = conn.GetFsError(vfs.ErrStorageSizeUnavailable)
+		if protocol == ProtocolSFTP {
+			assert.EqualError(t, err, sftp.ErrSSHFxOpUnsupported.Error())
+		} else {
+			assert.EqualError(t, err, vfs.ErrStorageSizeUnavailable.Error())
+		}
 		err = conn.GetFsError(nil)
 		assert.NoError(t, err)
 		err = conn.GetOpUnsupportedError()

+ 2 - 2
config/config.go

@@ -400,7 +400,7 @@ func LoadConfig(configDir, configFile string) error {
 	if strings.TrimSpace(globalConf.FTPD.Banner) == "" {
 		globalConf.FTPD.Banner = defaultFTPDBanner
 	}
-	if len(globalConf.ProviderConf.UsersBaseDir) > 0 && !utils.IsFileInputValid(globalConf.ProviderConf.UsersBaseDir) {
+	if globalConf.ProviderConf.UsersBaseDir != "" && !utils.IsFileInputValid(globalConf.ProviderConf.UsersBaseDir) {
 		err = fmt.Errorf("invalid users base dir %#v will be ignored", globalConf.ProviderConf.UsersBaseDir)
 		globalConf.ProviderConf.UsersBaseDir = ""
 		logger.Warn(logSender, "", "Configuration error: %v", err)
@@ -455,7 +455,7 @@ func checkCommonParamsCompatibility() {
 		logger.WarnToConsole("sftpd.idle_timeout is deprecated, please use common.idle_timeout")
 		globalConf.Common.IdleTimeout = globalConf.SFTPD.IdleTimeout //nolint:staticcheck
 	}
-	if len(globalConf.SFTPD.Actions.Hook) > 0 && len(globalConf.Common.Actions.Hook) == 0 { //nolint:staticcheck
+	if globalConf.SFTPD.Actions.Hook != "" && len(globalConf.Common.Actions.Hook) == 0 { //nolint:staticcheck
 		logger.Warn(logSender, "", "sftpd.actions is deprecated, please use common.actions")
 		logger.WarnToConsole("sftpd.actions is deprecated, please use common.actions")
 		globalConf.Common.Actions.ExecuteOn = globalConf.SFTPD.Actions.ExecuteOn //nolint:staticcheck

+ 4 - 4
dataprovider/dataprovider.go

@@ -2015,7 +2015,7 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro
 	if err != nil {
 		return u, fmt.Errorf("Pre-login hook error: %v", err)
 	}
-	if len(strings.TrimSpace(string(out))) == 0 {
+	if strings.TrimSpace(string(out)) == "" {
 		providerLog(logger.LevelDebug, "empty response from pre-login hook, no modification requested for user %#v id: %v",
 			username, u.ID)
 		if u.ID == 0 {
@@ -2182,13 +2182,13 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
 	if err != nil {
 		return user, fmt.Errorf("Invalid external auth response: %v", err)
 	}
-	if len(user.Username) == 0 {
+	if user.Username == "" {
 		return user, ErrInvalidCredentials
 	}
-	if len(password) > 0 {
+	if password != "" {
 		user.Password = password
 	}
-	if len(pkey) > 0 && !utils.IsStringPrefixInSlice(pkey, user.PublicKeys) {
+	if pkey != "" && !utils.IsStringPrefixInSlice(pkey, user.PublicKeys) {
 		user.PublicKeys = append(user.PublicKeys, pkey)
 	}
 	// some users want to map multiple login usernames with a single SFTPGo account

+ 3 - 9
dataprovider/user.go

@@ -718,22 +718,16 @@ func (u *User) GetQuotaSummary() string {
 func (u *User) GetPermissionsAsString() string {
 	result := ""
 	for dir, perms := range u.Permissions {
-		var dirPerms string
-		for _, p := range perms {
-			if len(dirPerms) > 0 {
-				dirPerms += ", "
-			}
-			dirPerms += p
-		}
+		dirPerms := strings.Join(perms, ", ")
 		dp := fmt.Sprintf("%#v: %#v", dir, dirPerms)
 		if dir == "/" {
-			if len(result) > 0 {
+			if result != "" {
 				result = dp + ", " + result
 			} else {
 				result = dp
 			}
 		} else {
-			if len(result) > 0 {
+			if result != "" {
 				result += ", "
 			}
 			result += dp

+ 1 - 1
docker/README.md

@@ -62,7 +62,7 @@ docker run --name some-sftpgo \
     -p 2022:2022 \
     --mount type=bind,source=/my/own/sftpgodata,target=/srv/sftpgo \
     --mount type=bind,source=/my/own/sftpgohome,target=/var/lib/sftpgo \
-    -e SFTPGO_HTTPD__BIND_PORT=8090 \
+    -e SFTPGO_HTTPD__BINDINGS__0__PORT=8090 \
     -d "drakkan/sftpgo:tag"
 ```
 

+ 7 - 4
ftpd/ftpd_test.go

@@ -16,6 +16,7 @@ import (
 	"path"
 	"path/filepath"
 	"runtime"
+	"strconv"
 	"testing"
 	"time"
 
@@ -1585,7 +1586,7 @@ func TestAllocateAvailable(t *testing.T) {
 	assert.NoError(t, err)
 }
 
-func TestAvailableUnsupportedFs(t *testing.T) {
+func TestAvailableSFTPFs(t *testing.T) {
 	u := getTestUser()
 	localUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
@@ -1593,10 +1594,12 @@ func TestAvailableUnsupportedFs(t *testing.T) {
 	assert.NoError(t, err)
 	client, err := getFTPClient(sftpUser, false)
 	if assert.NoError(t, err) {
-		code, response, err := client.SendCustomCommand("AVBL")
+		code, response, err := client.SendCustomCommand("AVBL /")
 		assert.NoError(t, err)
-		assert.Equal(t, ftp.StatusFileUnavailable, code)
-		assert.Contains(t, response, "unable to get available size for this storage backend")
+		assert.Equal(t, ftp.StatusFile, code)
+		avblSize, err := strconv.ParseInt(response, 10, 64)
+		assert.NoError(t, err)
+		assert.Greater(t, avblSize, int64(0))
 
 		err = client.Quit()
 		assert.NoError(t, err)

+ 9 - 6
ftpd/handler.go

@@ -213,8 +213,7 @@ func (c *Connection) Chtimes(name string, atime time.Time, mtime time.Time) erro
 func (c *Connection) GetAvailableSpace(dirName string) (int64, error) {
 	c.UpdateLastActivity()
 
-	quotaResult := c.HasSpace(false, path.Join(dirName, "fakefile.txt"))
-
+	quotaResult := c.HasSpace(false, false, path.Join(dirName, "fakefile.txt"))
 	if !quotaResult.HasSpace {
 		return 0, nil
 	}
@@ -230,7 +229,11 @@ func (c *Connection) GetAvailableSpace(dirName string) (int64, error) {
 			return 0, c.GetFsError(err)
 		}
 
-		return c.Fs.GetAvailableDiskSize(p)
+		statVFS, err := c.Fs.GetAvailableDiskSize(p)
+		if err != nil {
+			return 0, c.GetFsError(err)
+		}
+		return int64(statVFS.FreeSpace()), nil
 	}
 
 	// the available space is the minimum between MaxUploadFileSize, if setted,
@@ -260,7 +263,7 @@ func (c *Connection) AllocateSpace(size int) error {
 		folders = append(folders, path.Join(v.VirtualPath, "fakefile.txt"))
 	}
 	for _, f := range folders {
-		quotaResult := c.HasSpace(false, f)
+		quotaResult := c.HasSpace(false, false, f)
 		if quotaResult.HasSpace {
 			if quotaResult.QuotaSize == 0 {
 				// unlimited size is allowed
@@ -393,7 +396,7 @@ func (c *Connection) uploadFile(fsPath, ftpPath string, flags int) (ftpserver.Fi
 }
 
 func (c *Connection) handleFTPUploadToNewFile(resolvedPath, filePath, requestPath string) (ftpserver.FileTransfer, error) {
-	quotaResult := c.HasSpace(true, requestPath)
+	quotaResult := c.HasSpace(true, false, requestPath)
 	if !quotaResult.HasSpace {
 		c.Log(logger.LevelInfo, "denying file write due to quota limits")
 		return nil, common.ErrQuotaExceeded
@@ -419,7 +422,7 @@ func (c *Connection) handleFTPUploadToNewFile(resolvedPath, filePath, requestPat
 func (c *Connection) handleFTPUploadToExistingFile(flags int, resolvedPath, filePath string, fileSize int64,
 	requestPath string) (ftpserver.FileTransfer, error) {
 	var err error
-	quotaResult := c.HasSpace(false, requestPath)
+	quotaResult := c.HasSpace(false, false, requestPath)
 	if !quotaResult.HasSpace {
 		c.Log(logger.LevelInfo, "denying file write due to quota limits")
 		return nil, common.ErrQuotaExceeded

+ 21 - 0
ftpd/internal_test.go

@@ -616,6 +616,27 @@ func TestUploadFileStatError(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestAVBLErrors(t *testing.T) {
+	user := dataprovider.User{
+		Username: "user",
+		HomeDir:  filepath.Clean(os.TempDir()),
+	}
+	user.Permissions = make(map[string][]string)
+	user.Permissions["/"] = []string{dataprovider.PermAny}
+	mockCC := mockFTPClientContext{}
+	connID := fmt.Sprintf("%v", mockCC.ID())
+	fs := newMockOsFs(nil, nil, false, connID, user.GetHomeDir())
+	connection := &Connection{
+		BaseConnection: common.NewBaseConnection(connID, common.ProtocolFTP, user, fs),
+		clientContext:  mockCC,
+	}
+	_, err := connection.GetAvailableSpace("/")
+	assert.NoError(t, err)
+	_, err = connection.GetAvailableSpace("/missing-path")
+	assert.Error(t, err)
+	assert.True(t, os.IsNotExist(err))
+}
+
 func TestUploadOverwriteErrors(t *testing.T) {
 	user := dataprovider.User{
 		Username: "user",

+ 1 - 1
go.mod

@@ -71,7 +71,7 @@ require (
 
 replace (
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
-	github.com/pkg/sftp => github.com/drakkan/sftp v0.0.0-20201211115031-0b6bbc64f191
+	github.com/pkg/sftp => github.com/drakkan/sftp v0.0.0-20210210202350-a2b46fc9c0d5
 	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20201217113543-470e61ed2598
 	golang.org/x/net => github.com/drakkan/net v0.0.0-20210201075003-5fb2b186574d
 )

+ 2 - 2
go.sum

@@ -173,8 +173,8 @@ github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHP
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
 github.com/drakkan/net v0.0.0-20210201075003-5fb2b186574d h1:h2rU/lTUkEYB3y4k6+FgQNMajf4uE+sbMRn85kT+VTQ=
 github.com/drakkan/net v0.0.0-20210201075003-5fb2b186574d/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-github.com/drakkan/sftp v0.0.0-20201211115031-0b6bbc64f191 h1:c+RLqMs6Aqc8IDc5MWTf+zqNlO4+5WfiJqZzHFlr4a8=
-github.com/drakkan/sftp v0.0.0-20201211115031-0b6bbc64f191/go.mod h1:fUqqXB5vEgVCZ131L+9say31RAri6aF6KDViawhxKK8=
+github.com/drakkan/sftp v0.0.0-20210210202350-a2b46fc9c0d5 h1:jVxjoPrGY9Ypw65tTHRdDvumOE3ys2fLZfvFT6+gFPU=
+github.com/drakkan/sftp v0.0.0-20210210202350-a2b46fc9c0d5/go.mod h1:fUqqXB5vEgVCZ131L+9say31RAri6aF6KDViawhxKK8=
 github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
 github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=

+ 1 - 1
pkgs/build.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 
-NFPM_VERSION=2.2.3
+NFPM_VERSION=2.2.4
 NFPM_ARCH=${NFPM_ARCH:-amd64}
 if [ -z ${SFTPGO_VERSION} ]
 then

+ 1 - 1
service/service.go

@@ -272,7 +272,7 @@ func (s *Service) loadInitialData() error {
 func (s *Service) restoreDump(dump dataprovider.BackupData) error {
 	err := httpd.RestoreAdmins(dump.Admins, s.LoadDataFrom, s.LoadDataMode)
 	if err != nil {
-		return fmt.Errorf("unable to restore folders from file %#v: %v", s.LoadDataFrom, err)
+		return fmt.Errorf("unable to restore admins from file %#v: %v", s.LoadDataFrom, err)
 	}
 	err = httpd.RestoreFolders(dump.Folders, s.LoadDataFrom, s.LoadDataMode, s.LoadDataQuotaScan)
 	if err != nil {

+ 81 - 3
sftpd/handler.go

@@ -253,11 +253,43 @@ func (c *Connection) Lstat(request *sftp.Request) (sftp.ListerAt, error) {
 	return listerAt([]os.FileInfo{s}), nil
 }
 
+// StatVFS implements StatVFSFileCmder interface
+func (c *Connection) StatVFS(r *sftp.Request) (*sftp.StatVFS, error) {
+	c.UpdateLastActivity()
+
+	// we are assuming that r.Filepath is a dir, this could be wrong but should
+	// not produce any side effect here.
+	// we don't consider c.User.Filters.MaxUploadFileSize, we return disk stats here
+	// not the limit for a single file upload
+	quotaResult := c.HasSpace(true, true, path.Join(r.Filepath, "fakefile.txt"))
+
+	p, err := c.Fs.ResolvePath(r.Filepath)
+	if err != nil {
+		return nil, c.GetFsError(err)
+	}
+
+	if !quotaResult.HasSpace {
+		return c.getStatVFSFromQuotaResult(p, quotaResult), nil
+	}
+
+	if quotaResult.QuotaSize == 0 && quotaResult.QuotaFiles == 0 {
+		// no quota restrictions
+		statvfs, err := c.Fs.GetAvailableDiskSize(p)
+		if err == vfs.ErrStorageSizeUnavailable {
+			return c.getStatVFSFromQuotaResult(p, quotaResult), nil
+		}
+		return statvfs, err
+	}
+
+	// there is free space but some limits are configured
+	return c.getStatVFSFromQuotaResult(p, quotaResult), nil
+}
+
 func (c *Connection) getSFTPCmdTargetPath(requestTarget string) (string, error) {
 	var target string
 	// If a target is provided in this request validate that it is going to the correct
 	// location for the server. If it is not, return an error
-	if len(requestTarget) > 0 {
+	if requestTarget != "" {
 		var err error
 		target, err = c.Fs.ResolvePath(requestTarget)
 		if err != nil {
@@ -309,7 +341,7 @@ func (c *Connection) handleSFTPRemove(filePath string, request *sftp.Request) er
 }
 
 func (c *Connection) handleSFTPUploadToNewFile(resolvedPath, filePath, requestPath string, errForRead error) (sftp.WriterAtReaderAt, error) {
-	quotaResult := c.HasSpace(true, requestPath)
+	quotaResult := c.HasSpace(true, false, requestPath)
 	if !quotaResult.HasSpace {
 		c.Log(logger.LevelInfo, "denying file write due to quota limits")
 		return nil, sftp.ErrSSHFxFailure
@@ -336,7 +368,7 @@ func (c *Connection) handleSFTPUploadToNewFile(resolvedPath, filePath, requestPa
 func (c *Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, resolvedPath, filePath string,
 	fileSize int64, requestPath string, errForRead error) (sftp.WriterAtReaderAt, error) {
 	var err error
-	quotaResult := c.HasSpace(false, requestPath)
+	quotaResult := c.HasSpace(false, false, requestPath)
 	if !quotaResult.HasSpace {
 		c.Log(logger.LevelInfo, "denying file write due to quota limits")
 		return nil, sftp.ErrSSHFxFailure
@@ -406,6 +438,52 @@ func (c *Connection) Disconnect() error {
 	return c.channel.Close()
 }
 
+func (c *Connection) getStatVFSFromQuotaResult(name string, quotaResult vfs.QuotaCheckResult) *sftp.StatVFS {
+	if quotaResult.QuotaSize == 0 || quotaResult.QuotaFiles == 0 {
+		s, err := c.Fs.GetAvailableDiskSize(name)
+		if err == nil {
+			if quotaResult.QuotaSize == 0 {
+				quotaResult.QuotaSize = int64(s.TotalSpace())
+			}
+			if quotaResult.QuotaFiles == 0 {
+				quotaResult.QuotaFiles = int(s.Files)
+			}
+		}
+	}
+	// if we are unable to get quota size or quota files we add some arbitrary values
+	if quotaResult.QuotaSize == 0 {
+		quotaResult.QuotaSize = quotaResult.UsedSize + 8*1024*1024*1024*1024 // 8TB
+	}
+	if quotaResult.QuotaFiles == 0 {
+		quotaResult.QuotaFiles = quotaResult.UsedFiles + 1000000 // 1 million
+	}
+
+	bsize := uint64(4096)
+	for bsize > uint64(quotaResult.QuotaSize) {
+		bsize = bsize / 4
+	}
+	blocks := uint64(quotaResult.QuotaSize) / bsize
+	bfree := uint64(quotaResult.QuotaSize-quotaResult.UsedSize) / bsize
+	files := uint64(quotaResult.QuotaFiles)
+	ffree := uint64(quotaResult.QuotaFiles - quotaResult.UsedFiles)
+	if !quotaResult.HasSpace {
+		bfree = 0
+		ffree = 0
+	}
+
+	return &sftp.StatVFS{
+		Bsize:   bsize,
+		Frsize:  bsize,
+		Blocks:  blocks,
+		Bfree:   bfree,
+		Bavail:  bfree,
+		Files:   files,
+		Ffree:   ffree,
+		Favail:  ffree,
+		Namemax: 255,
+	}
+}
+
 func getOSOpenFlags(requestFlags sftp.FileOpenFlags) (flags int) {
 	var osFlags int
 	if requestFlags.Read && requestFlags.Write {

+ 6 - 1
sftpd/internal_test.go

@@ -376,6 +376,11 @@ func TestWithInvalidHome(t *testing.T) {
 	}
 	_, err = c.Fs.ResolvePath("../upper_path")
 	assert.Error(t, err, "tested path is not a home subdir")
+	_, err = c.StatVFS(&sftp.Request{
+		Method:   "StatVFS",
+		Filepath: "../unresolvable-path",
+	})
+	assert.Error(t, err)
 }
 
 func TestSFTPCmdTargetPath(t *testing.T) {
@@ -408,7 +413,7 @@ func TestSFTPGetUsedQuota(t *testing.T) {
 	connection := Connection{
 		BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, u, nil),
 	}
-	quotaResult := connection.HasSpace(false, "/")
+	quotaResult := connection.HasSpace(false, false, "/")
 	assert.False(t, quotaResult.HasSpace)
 }
 

+ 1 - 1
sftpd/scp.go

@@ -191,7 +191,7 @@ func (c *scpCommand) getUploadFileData(sizeToRead int64, transfer *transfer) err
 }
 
 func (c *scpCommand) handleUploadFile(resolvedPath, filePath string, sizeToRead int64, isNewFile bool, fileSize int64, requestPath string) error {
-	quotaResult := c.connection.HasSpace(isNewFile, requestPath)
+	quotaResult := c.connection.HasSpace(isNewFile, false, requestPath)
 	if !quotaResult.HasSpace {
 		err := fmt.Errorf("denying file write due to quota limits")
 		c.connection.Log(logger.LevelWarn, "error uploading file: %#v, err: %v", filePath, err)

+ 1 - 1
sftpd/server.go

@@ -33,7 +33,7 @@ const (
 )
 
 var (
-	sftpExtensions = []string{"posix-rename@openssh.com"}
+	sftpExtensions = []string{"statvfs@openssh.com"}
 )
 
 // Binding defines the configuration for a network listener

+ 136 - 0
sftpd/sftpd_test.go

@@ -456,6 +456,12 @@ func TestBasicSFTPFsHandling(t *testing.T) {
 		assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
 		assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
 
+		stat, err := client.StatVFS("/")
+		assert.NoError(t, err)
+		assert.Equal(t, uint64(u.QuotaSize/4096), stat.Blocks)
+		assert.Equal(t, uint64((u.QuotaSize-testFileSize)/4096), stat.Bfree)
+		assert.Equal(t, uint64(1), stat.Files-stat.Ffree)
+
 		err = os.Remove(testFilePath)
 		assert.NoError(t, err)
 		err = os.Remove(localDownloadPath)
@@ -6371,6 +6377,136 @@ func TestGetVirtualFolderForPath(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestStatVFS(t *testing.T) {
+	usePubKey := false
+	user, _, err := httpdtest.AddUser(getTestUser(usePubKey), http.StatusCreated)
+	assert.NoError(t, err)
+	testFileSize := int64(65535)
+	client, err := getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer client.Close()
+
+		stat, err := client.StatVFS("/")
+		assert.NoError(t, err)
+		assert.Greater(t, stat.ID, uint32(0))
+		assert.Greater(t, stat.Blocks, uint64(0))
+		assert.Greater(t, stat.Bsize, uint64(0))
+
+		_, err = client.StatVFS("missing-path")
+		assert.Error(t, err)
+		assert.True(t, os.IsNotExist(err))
+	}
+	user.QuotaFiles = 100
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	client, err = getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer client.Close()
+
+		testFilePath := filepath.Join(homeBasePath, testFileName)
+		err = createTestFile(testFilePath, testFileSize)
+		assert.NoError(t, err)
+		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
+		assert.NoError(t, err)
+		err = os.Remove(testFilePath)
+		assert.NoError(t, err)
+
+		stat, err := client.StatVFS("/")
+		assert.NoError(t, err)
+		assert.Greater(t, stat.ID, uint32(0))
+		assert.Greater(t, stat.Blocks, uint64(0))
+		assert.Greater(t, stat.Bsize, uint64(0))
+		assert.Equal(t, uint64(100), stat.Files)
+		assert.Equal(t, uint64(99), stat.Ffree)
+	}
+
+	user.QuotaSize = 8192
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	client, err = getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer client.Close()
+
+		stat, err := client.StatVFS("/")
+		assert.NoError(t, err)
+		assert.Greater(t, stat.ID, uint32(0))
+		assert.Greater(t, stat.Blocks, uint64(0))
+		assert.Greater(t, stat.Bsize, uint64(0))
+		assert.Equal(t, uint64(100), stat.Files)
+		assert.Equal(t, uint64(0), stat.Ffree)
+		assert.Equal(t, uint64(2), stat.Blocks)
+		assert.Equal(t, uint64(0), stat.Bfree)
+	}
+	user.QuotaFiles = 0
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	client, err = getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer client.Close()
+
+		stat, err := client.StatVFS("/")
+		assert.NoError(t, err)
+		assert.Greater(t, stat.ID, uint32(0))
+		assert.Greater(t, stat.Blocks, uint64(0))
+		assert.Greater(t, stat.Bsize, uint64(0))
+		assert.Greater(t, stat.Files, uint64(0))
+		assert.Equal(t, uint64(0), stat.Ffree)
+		assert.Equal(t, uint64(2), stat.Blocks)
+		assert.Equal(t, uint64(0), stat.Bfree)
+	}
+
+	user.QuotaSize = 1
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	client, err = getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer client.Close()
+
+		stat, err := client.StatVFS("/")
+		assert.NoError(t, err)
+		assert.Greater(t, stat.ID, uint32(0))
+		assert.Equal(t, uint64(1), stat.Blocks)
+		assert.Equal(t, uint64(1), stat.Bsize)
+		assert.Greater(t, stat.Files, uint64(0))
+		assert.Equal(t, uint64(0), stat.Ffree)
+		assert.Equal(t, uint64(1), stat.Blocks)
+		assert.Equal(t, uint64(0), stat.Bfree)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestStatVFSCloudBackend(t *testing.T) {
+	usePubKey := true
+	u := getTestUser(usePubKey)
+	u.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider
+	u.FsConfig.AzBlobConfig.SASURL = "https://myaccount.blob.core.windows.net/sasurl"
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	client, err := getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer client.Close()
+
+		err = dataprovider.UpdateUserQuota(user, 100, 8192, true)
+		assert.NoError(t, err)
+		stat, err := client.StatVFS("/")
+		assert.NoError(t, err)
+		assert.Greater(t, stat.ID, uint32(0))
+		assert.Greater(t, stat.Blocks, uint64(0))
+		assert.Greater(t, stat.Bsize, uint64(0))
+		assert.Equal(t, uint64(1000000+100), stat.Files)
+		assert.Equal(t, uint64(2147483648+2), stat.Blocks)
+		assert.Equal(t, uint64(1000000), stat.Ffree)
+		assert.Equal(t, uint64(2147483648), stat.Bfree)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestSSHCommands(t *testing.T) {
 	usePubKey := false
 	user, _, err := httpdtest.AddUser(getTestUser(usePubKey), http.StatusCreated)

+ 2 - 2
sftpd/ssh_cmd.go

@@ -309,7 +309,7 @@ func (c *sshCommand) executeSystemCommand(command systemCommand) error {
 		return c.sendErrorResponse(errUnsupportedConfig)
 	}
 	sshDestPath := c.getDestPath()
-	quotaResult := c.connection.HasSpace(true, command.quotaCheckPath)
+	quotaResult := c.connection.HasSpace(true, false, command.quotaCheckPath)
 	if !quotaResult.HasSpace {
 		return c.sendErrorResponse(common.ErrQuotaExceeded)
 	}
@@ -640,7 +640,7 @@ func (c *sshCommand) checkCopyDestination(fsDestPath string) error {
 }
 
 func (c *sshCommand) checkCopyQuota(numFiles int, filesSize int64, requestPath string) error {
-	quotaResult := c.connection.HasSpace(true, requestPath)
+	quotaResult := c.connection.HasSpace(true, false, requestPath)
 	if !quotaResult.HasSpace {
 		return common.ErrQuotaExceeded
 	}

+ 3 - 2
vfs/azblobfs.go

@@ -21,6 +21,7 @@ import (
 
 	"github.com/Azure/azure-storage-blob-go/azblob"
 	"github.com/eikenb/pipeat"
+	"github.com/pkg/sftp"
 
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/metrics"
@@ -702,8 +703,8 @@ func (*AzureBlobFs) Close() error {
 }
 
 // GetAvailableDiskSize return the available size for the specified path
-func (*AzureBlobFs) GetAvailableDiskSize(dirName string) (int64, error) {
-	return 0, errStorageSizeUnavailable
+func (*AzureBlobFs) GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error) {
+	return nil, ErrStorageSizeUnavailable
 }
 
 func (fs *AzureBlobFs) isEqual(key string, virtualName string) bool {

+ 3 - 2
vfs/gcsfs.go

@@ -18,6 +18,7 @@ import (
 
 	"cloud.google.com/go/storage"
 	"github.com/eikenb/pipeat"
+	"github.com/pkg/sftp"
 	"google.golang.org/api/googleapi"
 	"google.golang.org/api/iterator"
 	"google.golang.org/api/option"
@@ -696,6 +697,6 @@ func (fs *GCSFs) Close() error {
 }
 
 // GetAvailableDiskSize return the available size for the specified path
-func (*GCSFs) GetAvailableDiskSize(dirName string) (int64, error) {
-	return 0, errStorageSizeUnavailable
+func (*GCSFs) GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error) {
+	return nil, ErrStorageSizeUnavailable
 }

+ 3 - 7
vfs/osfs.go

@@ -11,8 +11,8 @@ import (
 	"time"
 
 	"github.com/eikenb/pipeat"
+	"github.com/pkg/sftp"
 	"github.com/rs/xid"
-	"github.com/shirou/gopsutil/v3/disk"
 
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/utils"
@@ -480,10 +480,6 @@ func (*OsFs) Close() error {
 }
 
 // GetAvailableDiskSize return the available size for the specified path
-func (*OsFs) GetAvailableDiskSize(dirName string) (int64, error) {
-	usage, err := disk.Usage(dirName)
-	if err != nil {
-		return 0, err
-	}
-	return int64(usage.Free), nil
+func (*OsFs) GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error) {
+	return getStatFS(dirName)
 }

+ 3 - 2
vfs/s3fs.go

@@ -20,6 +20,7 @@ import (
 	"github.com/aws/aws-sdk-go/service/s3"
 	"github.com/aws/aws-sdk-go/service/s3/s3manager"
 	"github.com/eikenb/pipeat"
+	"github.com/pkg/sftp"
 
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/metrics"
@@ -661,6 +662,6 @@ func (*S3Fs) Close() error {
 }
 
 // GetAvailableDiskSize return the available size for the specified path
-func (*S3Fs) GetAvailableDiskSize(dirName string) (int64, error) {
-	return 0, errStorageSizeUnavailable
+func (*S3Fs) GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error) {
+	return nil, ErrStorageSizeUnavailable
 }

+ 11 - 5
vfs/sftpfs.go

@@ -503,6 +503,17 @@ func (fs *SFTPFs) GetMimeType(name string) (string, error) {
 	return ctype, err
 }
 
+// GetAvailableDiskSize return the available size for the specified path
+func (fs *SFTPFs) GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error) {
+	if err := fs.checkConnection(); err != nil {
+		return nil, err
+	}
+	if _, ok := fs.sftpClient.HasExtension("statvfs@openssh.com"); !ok {
+		return nil, ErrStorageSizeUnavailable
+	}
+	return fs.sftpClient.StatVFS(dirName)
+}
+
 // Close the connection
 func (fs *SFTPFs) Close() error {
 	fs.Lock()
@@ -521,11 +532,6 @@ func (fs *SFTPFs) Close() error {
 	return sshErr
 }
 
-// GetAvailableDiskSize return the available size for the specified path
-func (*SFTPFs) GetAvailableDiskSize(dirName string) (int64, error) {
-	return 0, errStorageSizeUnavailable
-}
-
 func (fs *SFTPFs) checkConnection() error {
 	err := fs.closed()
 	if err == nil {

+ 38 - 0
vfs/statvfs_fallback.go

@@ -0,0 +1,38 @@
+// +build !darwin,!linux,!freebsd
+
+package vfs
+
+import (
+	"github.com/pkg/sftp"
+	"github.com/shirou/gopsutil/v3/disk"
+)
+
+const bsize = uint64(4096)
+
+func getStatFS(path string) (*sftp.StatVFS, error) {
+	usage, err := disk.Usage(path)
+	if err != nil {
+		return nil, err
+	}
+	// we assume block size = 4096
+	blocks := usage.Total / bsize
+	bfree := usage.Free / bsize
+	files := usage.InodesTotal
+	ffree := usage.InodesFree
+	if files == 0 {
+		// these assumptions are wrong but still better than returning 0
+		files = blocks / 4
+		ffree = bfree / 4
+	}
+	return &sftp.StatVFS{
+		Bsize:   bsize,
+		Frsize:  bsize,
+		Blocks:  blocks,
+		Bfree:   bfree,
+		Bavail:  bfree,
+		Files:   files,
+		Ffree:   ffree,
+		Favail:  ffree,
+		Namemax: 255,
+	}, nil
+}

+ 28 - 0
vfs/statvfs_linux.go

@@ -0,0 +1,28 @@
+// +build linux
+
+package vfs
+
+import (
+	"github.com/pkg/sftp"
+	"golang.org/x/sys/unix"
+)
+
+func getStatFS(path string) (*sftp.StatVFS, error) {
+	stat := unix.Statfs_t{}
+	err := unix.Statfs(path, &stat)
+	if err != nil {
+		return nil, err
+	}
+	return &sftp.StatVFS{
+		Bsize:   uint64(stat.Bsize),
+		Frsize:  uint64(stat.Frsize),
+		Blocks:  stat.Blocks,
+		Bfree:   stat.Bfree,
+		Bavail:  stat.Bavail,
+		Files:   stat.Files,
+		Ffree:   stat.Ffree,
+		Favail:  stat.Ffree, // not sure how to calculate Favail
+		Flag:    uint64(stat.Flags),
+		Namemax: uint64(stat.Namelen),
+	}, nil
+}

+ 28 - 0
vfs/statvfs_unix.go

@@ -0,0 +1,28 @@
+// +build freebsd darwin
+
+package vfs
+
+import (
+	"github.com/pkg/sftp"
+	"golang.org/x/sys/unix"
+)
+
+func getStatFS(path string) (*sftp.StatVFS, error) {
+	stat := unix.Statfs_t{}
+	err := unix.Statfs(path, &stat)
+	if err != nil {
+		return nil, err
+	}
+	return &sftp.StatVFS{
+		Bsize:   uint64(stat.Bsize),
+		Frsize:  uint64(stat.Bsize),
+		Blocks:  stat.Blocks,
+		Bfree:   stat.Bfree,
+		Bavail:  uint64(stat.Bavail),
+		Files:   stat.Files,
+		Ffree:   uint64(stat.Ffree),
+		Favail:  uint64(stat.Ffree), // not sure how to calculate Favail
+		Flag:    uint64(stat.Flags),
+		Namemax: 255, // we use a conservative value here
+	}, nil
+}

+ 5 - 3
vfs/vfs.go

@@ -14,6 +14,7 @@ import (
 	"time"
 
 	"github.com/eikenb/pipeat"
+	"github.com/pkg/sftp"
 
 	"github.com/drakkan/sftpgo/kms"
 	"github.com/drakkan/sftpgo/logger"
@@ -23,8 +24,9 @@ import (
 const dirMimeType = "inode/directory"
 
 var (
-	validAzAccessTier         = []string{"", "Archive", "Hot", "Cool"}
-	errStorageSizeUnavailable = errors.New("unable to get available size for this storage backend")
+	validAzAccessTier = []string{"", "Archive", "Hot", "Cool"}
+	// ErrStorageSizeUnavailable is returned if the storage backend does not support getting the size
+	ErrStorageSizeUnavailable = errors.New("unable to get available size for this storage backend")
 )
 
 // Fs defines the interface for filesystem backends
@@ -60,7 +62,7 @@ type Fs interface {
 	Join(elem ...string) string
 	HasVirtualFolders() bool
 	GetMimeType(name string) (string, error)
-	GetAvailableDiskSize(dirName string) (int64, error)
+	GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error)
 	Close() error
 }
 

+ 2 - 2
webdavd/handler.go

@@ -211,7 +211,7 @@ func (c *Connection) putFile(fsPath, virtualPath string) (webdav.File, error) {
 }
 
 func (c *Connection) handleUploadToNewFile(resolvedPath, filePath, requestPath string) (webdav.File, error) {
-	quotaResult := c.HasSpace(true, requestPath)
+	quotaResult := c.HasSpace(true, false, requestPath)
 	if !quotaResult.HasSpace {
 		c.Log(logger.LevelInfo, "denying file write due to quota limits")
 		return nil, common.ErrQuotaExceeded
@@ -236,7 +236,7 @@ func (c *Connection) handleUploadToNewFile(resolvedPath, filePath, requestPath s
 func (c *Connection) handleUploadToExistingFile(resolvedPath, filePath string, fileSize int64,
 	requestPath string) (webdav.File, error) {
 	var err error
-	quotaResult := c.HasSpace(false, requestPath)
+	quotaResult := c.HasSpace(false, false, requestPath)
 	if !quotaResult.HasSpace {
 		c.Log(logger.LevelInfo, "denying file write due to quota limits")
 		return nil, common.ErrQuotaExceeded