浏览代码

allow cross folder renaming if the underlying resource is the same

this was only allowed for the local filesystem before this change

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 3 年之前
父节点
当前提交
ced4206c5f

+ 8 - 8
go.mod

@@ -9,10 +9,10 @@ require (
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
 	github.com/aws/aws-sdk-go-v2 v1.16.11
 	github.com/aws/aws-sdk-go-v2 v1.16.11
-	github.com/aws/aws-sdk-go-v2/config v1.16.1
-	github.com/aws/aws-sdk-go-v2/credentials v1.12.13
+	github.com/aws/aws-sdk-go-v2/config v1.17.1
+	github.com/aws/aws-sdk-go-v2/credentials v1.12.14
 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12
 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12
-	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.25
+	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.27
 	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.12
 	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.12
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.27.5
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.27.5
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.17
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.17
@@ -51,7 +51,7 @@ require (
 	github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5
 	github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5
 	github.com/rs/xid v1.4.0
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.27.0
 	github.com/rs/zerolog v1.27.0
-	github.com/sftpgo/sdk v0.1.2-0.20220727164210-06723ba7ce9a
+	github.com/sftpgo/sdk v0.1.2-0.20220815185512-a4dc48b3993e
 	github.com/shirou/gopsutil/v3 v3.22.7
 	github.com/shirou/gopsutil/v3 v3.22.7
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/cobra v1.5.0
 	github.com/spf13/cobra v1.5.0
@@ -89,7 +89,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.13 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.13 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.12 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.12 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.12 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.12 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sso v1.11.16 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.11.17 // indirect
 	github.com/aws/smithy-go v1.12.1 // indirect
 	github.com/aws/smithy-go v1.12.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
@@ -124,8 +124,8 @@ require (
 	github.com/lestrrat-go/option v1.0.0 // indirect
 	github.com/lestrrat-go/option v1.0.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20220517141722-cf486979b281 // indirect
 	github.com/lufia/plan9stats v0.0.0-20220517141722-cf486979b281 // indirect
 	github.com/magiconair/properties v1.8.6 // indirect
 	github.com/magiconair/properties v1.8.6 // indirect
-	github.com/mattn/go-colorable v0.1.12 // indirect
-	github.com/mattn/go-isatty v0.0.14 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.16 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
 	github.com/miekg/dns v1.1.50 // indirect
 	github.com/miekg/dns v1.1.50 // indirect
 	github.com/minio/sha256-simd v1.0.0 // indirect
 	github.com/minio/sha256-simd v1.0.0 // indirect
@@ -155,7 +155,7 @@ require (
 	golang.org/x/tools v0.1.12 // indirect
 	golang.org/x/tools v0.1.12 // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220812140447-cec7f5303424 // indirect
+	google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959 // indirect
 	google.golang.org/grpc v1.48.0 // indirect
 	google.golang.org/grpc v1.48.0 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect

+ 16 - 14
go.sum

@@ -150,17 +150,17 @@ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1/go.mod h1:n8Bs1ElDD
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.4 h1:zfT11pa7ifu/VlLDpmc5OY2W4nYmnKkFDGeMVnmqAI0=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.4 h1:zfT11pa7ifu/VlLDpmc5OY2W4nYmnKkFDGeMVnmqAI0=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.4/go.mod h1:ES0I1GBs+YYgcDS1ek47Erbn4TOL811JKqBXtgzqyZ8=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.4/go.mod h1:ES0I1GBs+YYgcDS1ek47Erbn4TOL811JKqBXtgzqyZ8=
 github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg=
 github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg=
-github.com/aws/aws-sdk-go-v2/config v1.16.1 h1:jasqFPOoNPXHOYGEEuvyT87ACiXhD3OkQckIm5uqi5I=
-github.com/aws/aws-sdk-go-v2/config v1.16.1/go.mod h1:4SKzBMiB8lV0fw2w7eDBo/LjQyHFITN4vUUuqpurFmI=
+github.com/aws/aws-sdk-go-v2/config v1.17.1 h1:BWxTjokU/69BZ4DnLrZco6OvBDii6ToEdfBL/y5I1nA=
+github.com/aws/aws-sdk-go-v2/config v1.17.1/go.mod h1:uOxDHjBemNTF2Zos+fgG0NNfE86wn1OAHDTGxjMEYi0=
 github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g=
 github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g=
-github.com/aws/aws-sdk-go-v2/credentials v1.12.13 h1:cuPzIsjKAWBUAAk8ZUR2l02Sxafl9hiaMsc7tlnjwAY=
-github.com/aws/aws-sdk-go-v2/credentials v1.12.13/go.mod h1:9fDEemXizwXrxPU1MTzv69LP/9D8HVl5qHAQO9A9ikY=
+github.com/aws/aws-sdk-go-v2/credentials v1.12.14 h1:AtVG/amkjbDBfnPr/tuW2IG18HGNznP6L12Dx0rLz+Q=
+github.com/aws/aws-sdk-go-v2/credentials v1.12.14/go.mod h1:opAndTyq+YN7IpVG57z2CeNuXSQMqTYxGGlYH0m0RMY=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12 h1:wgJBHO58Pc1V1QAnzdVM3JK3WbE/6eUF0JxCZ+/izz0=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12 h1:wgJBHO58Pc1V1QAnzdVM3JK3WbE/6eUF0JxCZ+/izz0=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12/go.mod h1:aZ4vZnyUuxedC7eD4JyEHpGnCz+O2sHQEx3VvAwklSE=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12/go.mod h1:aZ4vZnyUuxedC7eD4JyEHpGnCz+O2sHQEx3VvAwklSE=
 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44=
 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.25 h1:ShUxLkMxarXylGxfYwg8p+xEKY+C1y54oUU3wFsUMFo=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.25/go.mod h1:cam5wV1ebd3ZVuh2r2CA8FtSAA/eUMtRH4owk0ygfFs=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.27 h1:xFXIMBci0UXStoOHq/8w0XIZPB2hgb9CD7uATJhqt10=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.27/go.mod h1:+tj2cHQkChanggNZn1J2fJ1Cv6RO1TV0AA3472do31I=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.18 h1:OmiwoVyLKEqqD5GvB683dbSqxiOfvx4U2lDZhG2Esc4=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.18 h1:OmiwoVyLKEqqD5GvB683dbSqxiOfvx4U2lDZhG2Esc4=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.18/go.mod h1:348MLhzV1GSlZSMusdwQpXKbhD7X2gbI/TxwAPKkYZQ=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.18/go.mod h1:348MLhzV1GSlZSMusdwQpXKbhD7X2gbI/TxwAPKkYZQ=
@@ -197,8 +197,8 @@ github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZP
 github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM=
 github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0=
 github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU=
 github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.16 h1:YK8L7TNlGwMWHYqLs+i6dlITpxqzq08FqQUy26nm+T8=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.16/go.mod h1:mS5xqLZc/6kc06IpXn5vRxdLaED+jEuaSRv5BxtnsiY=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.17 h1:pXxu9u2z1UqSbjO9YA8kmFJBhFc1EVTDaf7A+S+Ivq8=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.17/go.mod h1:mS5xqLZc/6kc06IpXn5vRxdLaED+jEuaSRv5BxtnsiY=
 github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8=
 github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8=
 github.com/aws/aws-sdk-go-v2/service/sts v1.16.13 h1:dl8T0PJlN92rvEGOEUiD0+YPYdPEaCZK0TqHukvSfII=
 github.com/aws/aws-sdk-go-v2/service/sts v1.16.13 h1:dl8T0PJlN92rvEGOEUiD0+YPYdPEaCZK0TqHukvSfII=
 github.com/aws/aws-sdk-go-v2/service/sts v1.16.13/go.mod h1:Ru3QVMLygVs/07UQ3YDur1AQZZp2tUNje8wfloFttC0=
 github.com/aws/aws-sdk-go-v2/service/sts v1.16.13/go.mod h1:Ru3QVMLygVs/07UQ3YDur1AQZZp2tUNje8wfloFttC0=
@@ -602,14 +602,16 @@ github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPK
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
 github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
 github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
 github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
 github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
 github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
 github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
 github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
 github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
@@ -712,8 +714,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
-github.com/sftpgo/sdk v0.1.2-0.20220727164210-06723ba7ce9a h1:X9qPZ+GPQ87TnBDNZN6dyX7FkjhwnFh98WgB6Y1T5O8=
-github.com/sftpgo/sdk v0.1.2-0.20220727164210-06723ba7ce9a/go.mod h1:RL4HeorXC6XgqtkLYnQUSogLdsdMfbsogIvdBVLuy4w=
+github.com/sftpgo/sdk v0.1.2-0.20220815185512-a4dc48b3993e h1:EJiTi+f2QCiDoGj1EBq6o1RX+JrtZnvTE6yKt3ks1B8=
+github.com/sftpgo/sdk v0.1.2-0.20220815185512-a4dc48b3993e/go.mod h1:RL4HeorXC6XgqtkLYnQUSogLdsdMfbsogIvdBVLuy4w=
 github.com/shirou/gopsutil/v3 v3.22.7 h1:flKnuCMfUUrO+oAvwAd6GKZgnPzr098VA/UJ14nhJd4=
 github.com/shirou/gopsutil/v3 v3.22.7 h1:flKnuCMfUUrO+oAvwAd6GKZgnPzr098VA/UJ14nhJd4=
 github.com/shirou/gopsutil/v3 v3.22.7/go.mod h1:s648gW4IywYzUfE/KjXxUsqrqx/T2xO5VqOXxONeRfI=
 github.com/shirou/gopsutil/v3 v3.22.7/go.mod h1:s648gW4IywYzUfE/KjXxUsqrqx/T2xO5VqOXxONeRfI=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
@@ -1226,8 +1228,8 @@ google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljW
 google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220812140447-cec7f5303424 h1:zZnTt15U44/Txe/9cN/tVbteBkPMiyXK48hPsKRmqj4=
-google.golang.org/genproto v0.0.0-20220812140447-cec7f5303424/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959 h1:hw4Y42zL1VyVKxPgRHHh191fpVBGV8sNVmcow5Z8VXY=
+google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=

+ 129 - 0
internal/common/common_test.go

@@ -1061,6 +1061,135 @@ func TestUserRecentActivity(t *testing.T) {
 	assert.True(t, res)
 	assert.True(t, res)
 }
 }
 
 
+func TestVfsSameResource(t *testing.T) {
+	fs := vfs.Filesystem{}
+	other := vfs.Filesystem{}
+	res := fs.IsSameResource(other)
+	assert.True(t, res)
+	fs = vfs.Filesystem{
+		Provider: sdk.S3FilesystemProvider,
+		S3Config: vfs.S3FsConfig{
+			BaseS3FsConfig: sdk.BaseS3FsConfig{
+				Bucket: "a",
+				Region: "b",
+			},
+		},
+	}
+	other = vfs.Filesystem{
+		Provider: sdk.S3FilesystemProvider,
+		S3Config: vfs.S3FsConfig{
+			BaseS3FsConfig: sdk.BaseS3FsConfig{
+				Bucket: "a",
+				Region: "c",
+			},
+		},
+	}
+	res = fs.IsSameResource(other)
+	assert.False(t, res)
+	other = vfs.Filesystem{
+		Provider: sdk.S3FilesystemProvider,
+		S3Config: vfs.S3FsConfig{
+			BaseS3FsConfig: sdk.BaseS3FsConfig{
+				Bucket: "a",
+				Region: "b",
+			},
+		},
+	}
+	res = fs.IsSameResource(other)
+	assert.True(t, res)
+	fs = vfs.Filesystem{
+		Provider: sdk.GCSFilesystemProvider,
+		GCSConfig: vfs.GCSFsConfig{
+			BaseGCSFsConfig: sdk.BaseGCSFsConfig{
+				Bucket: "b",
+			},
+		},
+	}
+	other = vfs.Filesystem{
+		Provider: sdk.GCSFilesystemProvider,
+		GCSConfig: vfs.GCSFsConfig{
+			BaseGCSFsConfig: sdk.BaseGCSFsConfig{
+				Bucket: "c",
+			},
+		},
+	}
+	res = fs.IsSameResource(other)
+	assert.False(t, res)
+	other = vfs.Filesystem{
+		Provider: sdk.GCSFilesystemProvider,
+		GCSConfig: vfs.GCSFsConfig{
+			BaseGCSFsConfig: sdk.BaseGCSFsConfig{
+				Bucket: "b",
+			},
+		},
+	}
+	res = fs.IsSameResource(other)
+	assert.True(t, res)
+	sasURL := kms.NewPlainSecret("http://127.0.0.1/sasurl")
+	fs = vfs.Filesystem{
+		Provider: sdk.AzureBlobFilesystemProvider,
+		AzBlobConfig: vfs.AzBlobFsConfig{
+			BaseAzBlobFsConfig: sdk.BaseAzBlobFsConfig{
+				AccountName: "a",
+			},
+			SASURL: sasURL,
+		},
+	}
+	err := fs.Validate("data1")
+	assert.NoError(t, err)
+	other = vfs.Filesystem{
+		Provider: sdk.AzureBlobFilesystemProvider,
+		AzBlobConfig: vfs.AzBlobFsConfig{
+			BaseAzBlobFsConfig: sdk.BaseAzBlobFsConfig{
+				AccountName: "a",
+			},
+			SASURL: sasURL,
+		},
+	}
+	err = other.Validate("data2")
+	assert.NoError(t, err)
+	err = fs.AzBlobConfig.SASURL.TryDecrypt()
+	assert.NoError(t, err)
+	err = other.AzBlobConfig.SASURL.TryDecrypt()
+	assert.NoError(t, err)
+	res = fs.IsSameResource(other)
+	assert.True(t, res)
+	fs.AzBlobConfig.AccountName = "b"
+	res = fs.IsSameResource(other)
+	assert.False(t, res)
+	fs.AzBlobConfig.AccountName = "a"
+	other.AzBlobConfig.SASURL = kms.NewPlainSecret("http://127.1.1.1/sasurl")
+	err = other.Validate("data2")
+	assert.NoError(t, err)
+	err = other.AzBlobConfig.SASURL.TryDecrypt()
+	assert.NoError(t, err)
+	res = fs.IsSameResource(other)
+	assert.False(t, res)
+	fs = vfs.Filesystem{
+		Provider: sdk.HTTPFilesystemProvider,
+		HTTPConfig: vfs.HTTPFsConfig{
+			BaseHTTPFsConfig: sdk.BaseHTTPFsConfig{
+				Endpoint: "http://127.0.0.1/httpfs",
+				Username: "a",
+			},
+		},
+	}
+	other = vfs.Filesystem{
+		Provider: sdk.HTTPFilesystemProvider,
+		HTTPConfig: vfs.HTTPFsConfig{
+			BaseHTTPFsConfig: sdk.BaseHTTPFsConfig{
+				Endpoint: "http://127.0.0.1/httpfs",
+				Username: "b",
+			},
+		},
+	}
+	res = fs.IsSameResource(other)
+	assert.True(t, res)
+	fs.HTTPConfig.EqualityCheckMode = 1
+	res = fs.IsSameResource(other)
+	assert.False(t, res)
+}
+
 func BenchmarkBcryptHashing(b *testing.B) {
 func BenchmarkBcryptHashing(b *testing.B) {
 	bcryptPassword := "bcryptpassword"
 	bcryptPassword := "bcryptpassword"
 	for i := 0; i < b.N; i++ {
 	for i := 0; i < b.N; i++ {

+ 8 - 23
internal/common/connection.go

@@ -526,7 +526,7 @@ func (c *BaseConnection) Rename(virtualSourcePath, virtualTargetPath string) err
 		c.Log(logger.LevelInfo, "denying cross rename due to space limit")
 		c.Log(logger.LevelInfo, "denying cross rename due to space limit")
 		return c.GetGenericError(ErrQuotaExceeded)
 		return c.GetGenericError(ErrQuotaExceeded)
 	}
 	}
-	if err := fsSrc.Rename(fsSourcePath, fsTargetPath); err != nil {
+	if err := fsDst.Rename(fsSourcePath, fsTargetPath); err != nil {
 		c.Log(logger.LevelError, "failed to rename %#v -> %#v: %+v", fsSourcePath, fsTargetPath, err)
 		c.Log(logger.LevelError, "failed to rename %#v -> %#v: %+v", fsSourcePath, fsTargetPath, err)
 		return c.GetFsError(fsSrc, err)
 		return c.GetFsError(fsSrc, err)
 	}
 	}
@@ -845,8 +845,8 @@ func (c *BaseConnection) hasRenamePerms(virtualSourcePath, virtualTargetPath str
 func (c *BaseConnection) isRenamePermitted(fsSrc, fsDst vfs.Fs, fsSourcePath, fsTargetPath, virtualSourcePath,
 func (c *BaseConnection) isRenamePermitted(fsSrc, fsDst vfs.Fs, fsSourcePath, fsTargetPath, virtualSourcePath,
 	virtualTargetPath string, fi os.FileInfo,
 	virtualTargetPath string, fi os.FileInfo,
 ) bool {
 ) bool {
-	if !c.isLocalOrSameFolderRename(virtualSourcePath, virtualTargetPath) {
-		c.Log(logger.LevelInfo, "rename %#v->%#v is not allowed: the paths must be local or on the same virtual folder",
+	if !c.isSameResourceRename(virtualSourcePath, virtualTargetPath) {
+		c.Log(logger.LevelInfo, "rename %#v->%#v is not allowed: the paths must be on the same resource",
 			virtualSourcePath, virtualTargetPath)
 			virtualSourcePath, virtualTargetPath)
 		return false
 		return false
 	}
 	}
@@ -1088,8 +1088,7 @@ func (c *BaseConnection) HasSpace(checkFiles, getUsage bool, requestPath string)
 	return result, transferQuota
 	return result, transferQuota
 }
 }
 
 
-// returns true if this is a rename on the same fs or local virtual folders
-func (c *BaseConnection) isLocalOrSameFolderRename(virtualSourcePath, virtualTargetPath string) bool {
+func (c *BaseConnection) isSameResourceRename(virtualSourcePath, virtualTargetPath string) bool {
 	sourceFolder, errSrc := c.User.GetVirtualFolderForPath(virtualSourcePath)
 	sourceFolder, errSrc := c.User.GetVirtualFolderForPath(virtualSourcePath)
 	dstFolder, errDst := c.User.GetVirtualFolderForPath(virtualTargetPath)
 	dstFolder, errDst := c.User.GetVirtualFolderForPath(virtualTargetPath)
 	if errSrc != nil && errDst != nil {
 	if errSrc != nil && errDst != nil {
@@ -1099,27 +1098,13 @@ func (c *BaseConnection) isLocalOrSameFolderRename(virtualSourcePath, virtualTar
 		if sourceFolder.Name == dstFolder.Name {
 		if sourceFolder.Name == dstFolder.Name {
 			return true
 			return true
 		}
 		}
-		// we have different folders, only local fs is supported
-		if sourceFolder.FsConfig.Provider == sdk.LocalFilesystemProvider &&
-			dstFolder.FsConfig.Provider == sdk.LocalFilesystemProvider {
-			return true
-		}
-		return false
-	}
-	if c.User.FsConfig.Provider != sdk.LocalFilesystemProvider {
-		return false
+		// we have different folders, check if they point to the same resource
+		return sourceFolder.FsConfig.IsSameResource(dstFolder.FsConfig)
 	}
 	}
 	if errSrc == nil {
 	if errSrc == nil {
-		if sourceFolder.FsConfig.Provider == sdk.LocalFilesystemProvider {
-			return true
-		}
+		return sourceFolder.FsConfig.IsSameResource(c.User.FsConfig)
 	}
 	}
-	if errDst == nil {
-		if dstFolder.FsConfig.Provider == sdk.LocalFilesystemProvider {
-			return true
-		}
-	}
-	return false
+	return dstFolder.FsConfig.IsSameResource(c.User.FsConfig)
 }
 }
 
 
 func (c *BaseConnection) isCrossFoldersRequest(virtualSourcePath, virtualTargetPath string) bool {
 func (c *BaseConnection) isCrossFoldersRequest(virtualSourcePath, virtualTargetPath string) bool {

+ 214 - 0
internal/common/protocol_test.go

@@ -55,6 +55,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/internal/kms"
 	"github.com/drakkan/sftpgo/v2/internal/kms"
 	"github.com/drakkan/sftpgo/v2/internal/logger"
 	"github.com/drakkan/sftpgo/v2/internal/logger"
 	"github.com/drakkan/sftpgo/v2/internal/mfa"
 	"github.com/drakkan/sftpgo/v2/internal/mfa"
+	"github.com/drakkan/sftpgo/v2/internal/sftpd"
 	"github.com/drakkan/sftpgo/v2/internal/smtp"
 	"github.com/drakkan/sftpgo/v2/internal/smtp"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 	"github.com/drakkan/sftpgo/v2/internal/vfs"
 	"github.com/drakkan/sftpgo/v2/internal/vfs"
@@ -132,6 +133,9 @@ func TestMain(m *testing.M) {
 
 
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf.Bindings[0].Port = 4022
 	sftpdConf.Bindings[0].Port = 4022
+	sftpdConf.Bindings = append(sftpdConf.Bindings, sftpd.Binding{
+		Port: 4024,
+	})
 	sftpdConf.KeyboardInteractiveAuthentication = true
 	sftpdConf.KeyboardInteractiveAuthentication = true
 
 
 	httpdConf := config.GetHTTPDConfig()
 	httpdConf := config.GetHTTPDConfig()
@@ -2309,6 +2313,216 @@ func TestVirtualFoldersLink(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestCrossFolderRename(t *testing.T) {
+	folder1 := "folder1"
+	folder2 := "folder2"
+	folder3 := "folder3"
+	folder4 := "folder4"
+	folder5 := "folder5"
+	folder6 := "folder6"
+	folder7 := "folder7"
+
+	baseUser, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+
+	u := getCryptFsUser()
+	u.Username += "_crypt"
+	u.VirtualFolders = []vfs.VirtualFolder{
+		{
+			BaseVirtualFolder: vfs.BaseVirtualFolder{
+				Name:       folder1,
+				MappedPath: filepath.Join(os.TempDir(), folder1),
+				FsConfig: vfs.Filesystem{
+					Provider: sdk.CryptedFilesystemProvider,
+					CryptConfig: vfs.CryptFsConfig{
+						Passphrase: kms.NewPlainSecret(defaultPassword),
+					},
+				},
+			},
+			VirtualPath: path.Join("/", folder1),
+			QuotaSize:   -1,
+			QuotaFiles:  -1,
+		},
+		{
+			BaseVirtualFolder: vfs.BaseVirtualFolder{
+				Name:       folder2,
+				MappedPath: filepath.Join(os.TempDir(), folder2),
+				FsConfig: vfs.Filesystem{
+					Provider: sdk.CryptedFilesystemProvider,
+					CryptConfig: vfs.CryptFsConfig{
+						Passphrase: kms.NewPlainSecret(defaultPassword),
+					},
+				},
+			},
+			VirtualPath: path.Join("/", folder2),
+			QuotaSize:   -1,
+			QuotaFiles:  -1,
+		},
+		{
+			BaseVirtualFolder: vfs.BaseVirtualFolder{
+				Name:       folder3,
+				MappedPath: filepath.Join(os.TempDir(), folder3),
+				FsConfig: vfs.Filesystem{
+					Provider: sdk.CryptedFilesystemProvider,
+					CryptConfig: vfs.CryptFsConfig{
+						Passphrase: kms.NewPlainSecret(defaultPassword + "mod"),
+					},
+				},
+			},
+			VirtualPath: path.Join("/", folder3),
+			QuotaSize:   -1,
+			QuotaFiles:  -1,
+		},
+		{
+			BaseVirtualFolder: vfs.BaseVirtualFolder{
+				Name:       folder4,
+				MappedPath: filepath.Join(os.TempDir(), folder4),
+				FsConfig: vfs.Filesystem{
+					Provider: sdk.SFTPFilesystemProvider,
+					SFTPConfig: vfs.SFTPFsConfig{
+						BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{
+							Endpoint: sftpServerAddr,
+							Username: baseUser.Username,
+							Prefix:   path.Join("/", folder4),
+						},
+						Password: kms.NewPlainSecret(defaultPassword),
+					},
+				},
+			},
+			VirtualPath: path.Join("/", folder4),
+			QuotaSize:   -1,
+			QuotaFiles:  -1,
+		},
+		{
+			BaseVirtualFolder: vfs.BaseVirtualFolder{
+				Name:       folder5,
+				MappedPath: filepath.Join(os.TempDir(), folder5),
+				FsConfig: vfs.Filesystem{
+					Provider: sdk.SFTPFilesystemProvider,
+					SFTPConfig: vfs.SFTPFsConfig{
+						BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{
+							Endpoint: sftpServerAddr,
+							Username: baseUser.Username,
+							Prefix:   path.Join("/", folder5),
+						},
+						Password: kms.NewPlainSecret(defaultPassword),
+					},
+				},
+			},
+			VirtualPath: path.Join("/", folder5),
+			QuotaSize:   -1,
+			QuotaFiles:  -1,
+		},
+		{
+			BaseVirtualFolder: vfs.BaseVirtualFolder{
+				Name:       folder6,
+				MappedPath: filepath.Join(os.TempDir(), folder6),
+				FsConfig: vfs.Filesystem{
+					Provider: sdk.SFTPFilesystemProvider,
+					SFTPConfig: vfs.SFTPFsConfig{
+						BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{
+							Endpoint: "127.0.0.1:4024",
+							Username: baseUser.Username,
+							Prefix:   path.Join("/", folder6),
+						},
+						Password: kms.NewPlainSecret(defaultPassword),
+					},
+				},
+			},
+			VirtualPath: path.Join("/", folder6),
+			QuotaSize:   -1,
+			QuotaFiles:  -1,
+		},
+		{
+			BaseVirtualFolder: vfs.BaseVirtualFolder{
+				Name:       folder7,
+				MappedPath: filepath.Join(os.TempDir(), folder7),
+				FsConfig: vfs.Filesystem{
+					Provider: sdk.SFTPFilesystemProvider,
+					SFTPConfig: vfs.SFTPFsConfig{
+						BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{
+							Endpoint: sftpServerAddr,
+							Username: baseUser.Username,
+							Prefix:   path.Join("/", folder4),
+						},
+						Password: kms.NewPlainSecret(defaultPassword),
+					},
+				},
+			},
+			VirtualPath: path.Join("/", folder7),
+			QuotaSize:   -1,
+			QuotaFiles:  -1,
+		},
+	}
+
+	user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		subDir := "testSubDir"
+		err = client.Mkdir(subDir)
+		assert.NoError(t, err)
+		err = writeSFTPFile(path.Join(subDir, "afile.bin"), 64, client)
+		assert.NoError(t, err)
+		err = client.Rename(subDir, path.Join("/", folder1, subDir))
+		assert.NoError(t, err)
+		_, err = client.Stat(path.Join("/", folder1, subDir))
+		assert.NoError(t, err)
+		_, err = client.Stat(path.Join("/", folder1, subDir, "afile.bin"))
+		assert.NoError(t, err)
+		err = client.Rename(path.Join("/", folder1, subDir), path.Join("/", folder2, subDir))
+		assert.NoError(t, err)
+		_, err = client.Stat(path.Join("/", folder2, subDir))
+		assert.NoError(t, err)
+		_, err = client.Stat(path.Join("/", folder2, subDir, "afile.bin"))
+		assert.NoError(t, err)
+		err = client.Rename(path.Join("/", folder2, subDir), path.Join("/", folder3, subDir))
+		assert.ErrorIs(t, err, os.ErrPermission)
+		err = writeSFTPFile(path.Join("/", folder3, "file.bin"), 64, client)
+		assert.NoError(t, err)
+		err = client.Rename(path.Join("/", folder3, "file.bin"), "/renamed.bin")
+		assert.ErrorIs(t, err, os.ErrPermission)
+		err = client.Rename(path.Join("/", folder3, "file.bin"), path.Join("/", folder2, "/renamed.bin"))
+		assert.ErrorIs(t, err, os.ErrPermission)
+		err = client.Rename(path.Join("/", folder3, "file.bin"), path.Join("/", folder3, "/renamed.bin"))
+		assert.NoError(t, err)
+		err = writeSFTPFile("/afile.bin", 64, client)
+		assert.NoError(t, err)
+		err = client.Rename("afile.bin", path.Join("/", folder4, "afile_renamed.bin"))
+		assert.ErrorIs(t, err, os.ErrPermission)
+		err = writeSFTPFile(path.Join("/", folder4, "afile.bin"), 64, client)
+		assert.NoError(t, err)
+		err = client.Rename(path.Join("/", folder4, "afile.bin"), path.Join("/", folder5, "afile_renamed.bin"))
+		assert.NoError(t, err)
+		err = client.Rename(path.Join("/", folder5, "afile_renamed.bin"), path.Join("/", folder6, "afile_renamed.bin"))
+		assert.ErrorIs(t, err, os.ErrPermission)
+		err = writeSFTPFile(path.Join("/", folder4, "afile.bin"), 64, client)
+		assert.NoError(t, err)
+		_, err = client.Stat(path.Join("/", folder7, "afile.bin"))
+		assert.NoError(t, err)
+		err = client.Rename(path.Join("/", folder4, "afile.bin"), path.Join("/", folder7, "afile.bin"))
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(baseUser, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(baseUser.GetHomeDir())
+	assert.NoError(t, err)
+	for _, folderName := range []string{folder1, folder2, folder3, folder4, folder5, folder6, folder7} {
+		_, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName}, http.StatusOK)
+		assert.NoError(t, err)
+		err = os.RemoveAll(filepath.Join(os.TempDir(), folderName))
+		assert.NoError(t, err)
+	}
+}
+
 func TestDirs(t *testing.T) {
 func TestDirs(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	mappedPath := filepath.Join(os.TempDir(), "vdir")
 	mappedPath := filepath.Join(os.TempDir(), "vdir")

+ 2 - 2
internal/dataprovider/user.go

@@ -300,7 +300,7 @@ func (u *User) isFsEqual(other *User) bool {
 	if u.FsConfig.Provider == sdk.LocalFilesystemProvider && u.GetHomeDir() != other.GetHomeDir() {
 	if u.FsConfig.Provider == sdk.LocalFilesystemProvider && u.GetHomeDir() != other.GetHomeDir() {
 		return false
 		return false
 	}
 	}
-	if !u.FsConfig.IsEqual(&other.FsConfig) {
+	if !u.FsConfig.IsEqual(other.FsConfig) {
 		return false
 		return false
 	}
 	}
 	if u.Filters.StartDirectory != other.Filters.StartDirectory {
 	if u.Filters.StartDirectory != other.Filters.StartDirectory {
@@ -319,7 +319,7 @@ func (u *User) isFsEqual(other *User) bool {
 				if f.FsConfig.Provider == sdk.LocalFilesystemProvider && f.MappedPath != f1.MappedPath {
 				if f.FsConfig.Provider == sdk.LocalFilesystemProvider && f.MappedPath != f1.MappedPath {
 					return false
 					return false
 				}
 				}
-				if !f.FsConfig.IsEqual(&f1.FsConfig) {
+				if !f.FsConfig.IsEqual(f1.FsConfig) {
 					return false
 					return false
 				}
 				}
 			}
 			}

+ 9 - 0
internal/httpd/httpd_test.go

@@ -4300,6 +4300,7 @@ func TestUserSFTPFs(t *testing.T) {
 	user.FsConfig.SFTPConfig.PrivateKey = kms.NewPlainSecret(sftpPrivateKey)
 	user.FsConfig.SFTPConfig.PrivateKey = kms.NewPlainSecret(sftpPrivateKey)
 	user.FsConfig.SFTPConfig.Fingerprints = []string{sftpPkeyFingerprint}
 	user.FsConfig.SFTPConfig.Fingerprints = []string{sftpPkeyFingerprint}
 	user.FsConfig.SFTPConfig.BufferSize = 2
 	user.FsConfig.SFTPConfig.BufferSize = 2
+	user.FsConfig.SFTPConfig.EqualityCheckMode = 1
 	_, resp, err := httpdtest.UpdateUser(user, http.StatusBadRequest, "")
 	_, resp, err := httpdtest.UpdateUser(user, http.StatusBadRequest, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "invalid endpoint")
 	assert.Contains(t, string(resp), "invalid endpoint")
@@ -18070,6 +18071,7 @@ func TestWebUserHTTPFsMock(t *testing.T) {
 	form.Set("patterns1", "*.zip")
 	form.Set("patterns1", "*.zip")
 	form.Set("pattern_type1", "denied")
 	form.Set("pattern_type1", "denied")
 	form.Set("max_upload_file_size", "0")
 	form.Set("max_upload_file_size", "0")
+	form.Set("http_equality_check_mode", "true")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
 	setJWTCookieForReq(req, webToken)
 	setJWTCookieForReq(req, webToken)
@@ -18097,7 +18099,9 @@ func TestWebUserHTTPFsMock(t *testing.T) {
 	assert.NotEmpty(t, updateUser.FsConfig.HTTPConfig.APIKey.GetPayload())
 	assert.NotEmpty(t, updateUser.FsConfig.HTTPConfig.APIKey.GetPayload())
 	assert.Empty(t, updateUser.FsConfig.HTTPConfig.APIKey.GetKey())
 	assert.Empty(t, updateUser.FsConfig.HTTPConfig.APIKey.GetKey())
 	assert.Empty(t, updateUser.FsConfig.HTTPConfig.APIKey.GetAdditionalData())
 	assert.Empty(t, updateUser.FsConfig.HTTPConfig.APIKey.GetAdditionalData())
+	assert.Equal(t, 1, updateUser.FsConfig.HTTPConfig.EqualityCheckMode)
 	// now check that a redacted password is not saved
 	// now check that a redacted password is not saved
+	form.Set("http_equality_check_mode", "")
 	form.Set("http_password", " "+redactedSecret+" ")
 	form.Set("http_password", " "+redactedSecret+" ")
 	form.Set("http_api_key", redactedSecret)
 	form.Set("http_api_key", redactedSecret)
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	b, contentType, _ = getMultipartFormData(form, "", "")
@@ -18121,6 +18125,7 @@ func TestWebUserHTTPFsMock(t *testing.T) {
 	assert.Equal(t, updateUser.FsConfig.HTTPConfig.APIKey.GetPayload(), lastUpdatedUser.FsConfig.HTTPConfig.APIKey.GetPayload())
 	assert.Equal(t, updateUser.FsConfig.HTTPConfig.APIKey.GetPayload(), lastUpdatedUser.FsConfig.HTTPConfig.APIKey.GetPayload())
 	assert.Empty(t, lastUpdatedUser.FsConfig.HTTPConfig.APIKey.GetKey())
 	assert.Empty(t, lastUpdatedUser.FsConfig.HTTPConfig.APIKey.GetKey())
 	assert.Empty(t, lastUpdatedUser.FsConfig.HTTPConfig.APIKey.GetAdditionalData())
 	assert.Empty(t, lastUpdatedUser.FsConfig.HTTPConfig.APIKey.GetAdditionalData())
+	assert.Equal(t, 0, lastUpdatedUser.FsConfig.HTTPConfig.EqualityCheckMode)
 
 
 	req, err = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
 	req, err = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -18491,6 +18496,7 @@ func TestWebUserSFTPFsMock(t *testing.T) {
 	form.Set("sftp_fingerprints", user.FsConfig.SFTPConfig.Fingerprints[0])
 	form.Set("sftp_fingerprints", user.FsConfig.SFTPConfig.Fingerprints[0])
 	form.Set("sftp_prefix", user.FsConfig.SFTPConfig.Prefix)
 	form.Set("sftp_prefix", user.FsConfig.SFTPConfig.Prefix)
 	form.Set("sftp_disable_concurrent_reads", "true")
 	form.Set("sftp_disable_concurrent_reads", "true")
+	form.Set("sftp_equality_check_mode", "true")
 	form.Set("sftp_buffer_size", strconv.FormatInt(user.FsConfig.SFTPConfig.BufferSize, 10))
 	form.Set("sftp_buffer_size", strconv.FormatInt(user.FsConfig.SFTPConfig.BufferSize, 10))
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@@ -18526,10 +18532,12 @@ func TestWebUserSFTPFsMock(t *testing.T) {
 	assert.Len(t, updateUser.FsConfig.SFTPConfig.Fingerprints, 1)
 	assert.Len(t, updateUser.FsConfig.SFTPConfig.Fingerprints, 1)
 	assert.Equal(t, user.FsConfig.SFTPConfig.BufferSize, updateUser.FsConfig.SFTPConfig.BufferSize)
 	assert.Equal(t, user.FsConfig.SFTPConfig.BufferSize, updateUser.FsConfig.SFTPConfig.BufferSize)
 	assert.Contains(t, updateUser.FsConfig.SFTPConfig.Fingerprints, sftpPkeyFingerprint)
 	assert.Contains(t, updateUser.FsConfig.SFTPConfig.Fingerprints, sftpPkeyFingerprint)
+	assert.Equal(t, 1, updateUser.FsConfig.SFTPConfig.EqualityCheckMode)
 	// now check that a redacted credentials are not saved
 	// now check that a redacted credentials are not saved
 	form.Set("sftp_password", redactedSecret+" ")
 	form.Set("sftp_password", redactedSecret+" ")
 	form.Set("sftp_private_key", redactedSecret)
 	form.Set("sftp_private_key", redactedSecret)
 	form.Set("sftp_key_passphrase", redactedSecret)
 	form.Set("sftp_key_passphrase", redactedSecret)
+	form.Set("sftp_equality_check_mode", "")
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
 	setJWTCookieForReq(req, webToken)
 	setJWTCookieForReq(req, webToken)
@@ -18555,6 +18563,7 @@ func TestWebUserSFTPFsMock(t *testing.T) {
 	assert.Equal(t, updateUser.FsConfig.SFTPConfig.KeyPassphrase.GetPayload(), lastUpdatedUser.FsConfig.SFTPConfig.KeyPassphrase.GetPayload())
 	assert.Equal(t, updateUser.FsConfig.SFTPConfig.KeyPassphrase.GetPayload(), lastUpdatedUser.FsConfig.SFTPConfig.KeyPassphrase.GetPayload())
 	assert.Empty(t, lastUpdatedUser.FsConfig.SFTPConfig.KeyPassphrase.GetKey())
 	assert.Empty(t, lastUpdatedUser.FsConfig.SFTPConfig.KeyPassphrase.GetKey())
 	assert.Empty(t, lastUpdatedUser.FsConfig.SFTPConfig.KeyPassphrase.GetAdditionalData())
 	assert.Empty(t, lastUpdatedUser.FsConfig.SFTPConfig.KeyPassphrase.GetAdditionalData())
+	assert.Equal(t, 0, lastUpdatedUser.FsConfig.SFTPConfig.EqualityCheckMode)
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
 	setBearerForReq(req, apiToken)
 	setBearerForReq(req, apiToken)
 	rr = executeRequest(req)
 	rr = executeRequest(req)

+ 10 - 0
internal/httpd/webadmin.go

@@ -1414,6 +1414,11 @@ func getSFTPConfig(r *http.Request) (vfs.SFTPFsConfig, error) {
 	config.Prefix = r.Form.Get("sftp_prefix")
 	config.Prefix = r.Form.Get("sftp_prefix")
 	config.DisableCouncurrentReads = r.Form.Get("sftp_disable_concurrent_reads") != ""
 	config.DisableCouncurrentReads = r.Form.Get("sftp_disable_concurrent_reads") != ""
 	config.BufferSize, err = strconv.ParseInt(r.Form.Get("sftp_buffer_size"), 10, 64)
 	config.BufferSize, err = strconv.ParseInt(r.Form.Get("sftp_buffer_size"), 10, 64)
+	if r.Form.Get("sftp_equality_check_mode") != "" {
+		config.EqualityCheckMode = 1
+	} else {
+		config.EqualityCheckMode = 0
+	}
 	if err != nil {
 	if err != nil {
 		return config, fmt.Errorf("invalid SFTP buffer size: %w", err)
 		return config, fmt.Errorf("invalid SFTP buffer size: %w", err)
 	}
 	}
@@ -1427,6 +1432,11 @@ func getHTTPFsConfig(r *http.Request) vfs.HTTPFsConfig {
 	config.SkipTLSVerify = r.Form.Get("http_skip_tls_verify") != ""
 	config.SkipTLSVerify = r.Form.Get("http_skip_tls_verify") != ""
 	config.Password = getSecretFromFormField(r, "http_password")
 	config.Password = getSecretFromFormField(r, "http_password")
 	config.APIKey = getSecretFromFormField(r, "http_api_key")
 	config.APIKey = getSecretFromFormField(r, "http_api_key")
+	if r.Form.Get("http_equality_check_mode") != "" {
+		config.EqualityCheckMode = 1
+	} else {
+		config.EqualityCheckMode = 0
+	}
 	return config
 	return config
 }
 }
 
 

+ 6 - 0
internal/httpdtest/httpdtest.go

@@ -1867,6 +1867,9 @@ func compareHTTPFsConfig(expected *vfs.Filesystem, actual *vfs.Filesystem) error
 	if expected.HTTPConfig.SkipTLSVerify != actual.HTTPConfig.SkipTLSVerify {
 	if expected.HTTPConfig.SkipTLSVerify != actual.HTTPConfig.SkipTLSVerify {
 		return errors.New("HTTPFs skip_tls_verify mismatch")
 		return errors.New("HTTPFs skip_tls_verify mismatch")
 	}
 	}
+	if expected.SFTPConfig.EqualityCheckMode != actual.SFTPConfig.EqualityCheckMode {
+		return errors.New("HTTPFs equality_check_mode mismatch")
+	}
 	if err := checkEncryptedSecret(expected.HTTPConfig.Password, actual.HTTPConfig.Password); err != nil {
 	if err := checkEncryptedSecret(expected.HTTPConfig.Password, actual.HTTPConfig.Password); err != nil {
 		return fmt.Errorf("HTTPFs password mismatch: %v", err)
 		return fmt.Errorf("HTTPFs password mismatch: %v", err)
 	}
 	}
@@ -1889,6 +1892,9 @@ func compareSFTPFsConfig(expected *vfs.Filesystem, actual *vfs.Filesystem) error
 	if expected.SFTPConfig.BufferSize != actual.SFTPConfig.BufferSize {
 	if expected.SFTPConfig.BufferSize != actual.SFTPConfig.BufferSize {
 		return errors.New("SFTPFs buffer_size mismatch")
 		return errors.New("SFTPFs buffer_size mismatch")
 	}
 	}
+	if expected.SFTPConfig.EqualityCheckMode != actual.SFTPConfig.EqualityCheckMode {
+		return errors.New("SFTPFs equality_check_mode mismatch")
+	}
 	if err := checkEncryptedSecret(expected.SFTPConfig.Password, actual.SFTPConfig.Password); err != nil {
 	if err := checkEncryptedSecret(expected.SFTPConfig.Password, actual.SFTPConfig.Password); err != nil {
 		return fmt.Errorf("SFTPFs password mismatch: %v", err)
 		return fmt.Errorf("SFTPFs password mismatch: %v", err)
 	}
 	}

+ 4 - 2
internal/sftpd/httpfs_test.go

@@ -198,8 +198,9 @@ func TestHTTPFsVirtualFolder(t *testing.T) {
 				Provider: sdk.HTTPFilesystemProvider,
 				Provider: sdk.HTTPFilesystemProvider,
 				HTTPConfig: vfs.HTTPFsConfig{
 				HTTPConfig: vfs.HTTPFsConfig{
 					BaseHTTPFsConfig: sdk.BaseHTTPFsConfig{
 					BaseHTTPFsConfig: sdk.BaseHTTPFsConfig{
-						Endpoint: fmt.Sprintf("http://127.0.0.1:%d/api/v1", httpFsPort),
-						Username: defaultHTTPFsUsername,
+						Endpoint:          fmt.Sprintf("http://127.0.0.1:%d/api/v1", httpFsPort),
+						Username:          defaultHTTPFsUsername,
+						EqualityCheckMode: 1,
 					},
 					},
 				},
 				},
 			},
 			},
@@ -240,6 +241,7 @@ func TestHTTPFsVirtualFolder(t *testing.T) {
 
 
 func TestHTTPFsWalk(t *testing.T) {
 func TestHTTPFsWalk(t *testing.T) {
 	user := getTestUserWithHTTPFs(false)
 	user := getTestUserWithHTTPFs(false)
+	user.FsConfig.HTTPConfig.EqualityCheckMode = 1
 	httpFs, err := user.GetFilesystem("")
 	httpFs, err := user.GetFilesystem("")
 	require.NoError(t, err)
 	require.NoError(t, err)
 	basePath := filepath.Join(os.TempDir(), "httpfs", user.FsConfig.HTTPConfig.Username)
 	basePath := filepath.Join(os.TempDir(), "httpfs", user.FsConfig.HTTPConfig.Username)

+ 3 - 2
internal/sftpd/sftpd_test.go

@@ -5224,8 +5224,9 @@ func TestSFTPLoopVirtualFolders(t *testing.T) {
 				Provider: sdk.SFTPFilesystemProvider,
 				Provider: sdk.SFTPFilesystemProvider,
 				SFTPConfig: vfs.SFTPFsConfig{
 				SFTPConfig: vfs.SFTPFsConfig{
 					BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{
 					BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{
-						Endpoint: sftpServerAddr,
-						Username: user2.Username,
+						Endpoint:          sftpServerAddr,
+						Username:          user2.Username,
+						EqualityCheckMode: 1,
 					},
 					},
 					Password: kms.NewPlainSecret(defaultPassword),
 					Password: kms.NewPlainSecret(defaultPassword),
 				},
 				},

+ 35 - 10
internal/vfs/filesystem.go

@@ -104,23 +104,46 @@ func (f *Filesystem) SetNilSecretsIfEmpty() {
 }
 }
 
 
 // IsEqual returns true if the fs is equal to other
 // IsEqual returns true if the fs is equal to other
-func (f *Filesystem) IsEqual(other *Filesystem) bool {
+func (f *Filesystem) IsEqual(other Filesystem) bool {
 	if f.Provider != other.Provider {
 	if f.Provider != other.Provider {
 		return false
 		return false
 	}
 	}
 	switch f.Provider {
 	switch f.Provider {
 	case sdk.S3FilesystemProvider:
 	case sdk.S3FilesystemProvider:
-		return f.S3Config.isEqual(&other.S3Config)
+		return f.S3Config.isEqual(other.S3Config)
 	case sdk.GCSFilesystemProvider:
 	case sdk.GCSFilesystemProvider:
-		return f.GCSConfig.isEqual(&other.GCSConfig)
+		return f.GCSConfig.isEqual(other.GCSConfig)
 	case sdk.AzureBlobFilesystemProvider:
 	case sdk.AzureBlobFilesystemProvider:
-		return f.AzBlobConfig.isEqual(&other.AzBlobConfig)
+		return f.AzBlobConfig.isEqual(other.AzBlobConfig)
 	case sdk.CryptedFilesystemProvider:
 	case sdk.CryptedFilesystemProvider:
-		return f.CryptConfig.isEqual(&other.CryptConfig)
+		return f.CryptConfig.isEqual(other.CryptConfig)
 	case sdk.SFTPFilesystemProvider:
 	case sdk.SFTPFilesystemProvider:
-		return f.SFTPConfig.isEqual(&other.SFTPConfig)
+		return f.SFTPConfig.isEqual(other.SFTPConfig)
 	case sdk.HTTPFilesystemProvider:
 	case sdk.HTTPFilesystemProvider:
-		return f.HTTPConfig.isEqual(&other.HTTPConfig)
+		return f.HTTPConfig.isEqual(other.HTTPConfig)
+	default:
+		return true
+	}
+}
+
+// IsSameResource returns true if fs point to the same resource as other
+func (f *Filesystem) IsSameResource(other Filesystem) bool {
+	if f.Provider != other.Provider {
+		return false
+	}
+	switch f.Provider {
+	case sdk.S3FilesystemProvider:
+		return f.S3Config.isSameResource(other.S3Config)
+	case sdk.GCSFilesystemProvider:
+		return f.GCSConfig.isSameResource(other.GCSConfig)
+	case sdk.AzureBlobFilesystemProvider:
+		return f.AzBlobConfig.isSameResource(other.AzBlobConfig)
+	case sdk.CryptedFilesystemProvider:
+		return f.CryptConfig.isSameResource(other.CryptConfig)
+	case sdk.SFTPFilesystemProvider:
+		return f.SFTPConfig.isSameResource(other.SFTPConfig)
+	case sdk.HTTPFilesystemProvider:
+		return f.HTTPConfig.isSameResource(other.HTTPConfig)
 	default:
 	default:
 		return true
 		return true
 	}
 	}
@@ -314,6 +337,7 @@ func (f *Filesystem) GetACopy() Filesystem {
 				Prefix:                  f.SFTPConfig.Prefix,
 				Prefix:                  f.SFTPConfig.Prefix,
 				DisableCouncurrentReads: f.SFTPConfig.DisableCouncurrentReads,
 				DisableCouncurrentReads: f.SFTPConfig.DisableCouncurrentReads,
 				BufferSize:              f.SFTPConfig.BufferSize,
 				BufferSize:              f.SFTPConfig.BufferSize,
+				EqualityCheckMode:       f.SFTPConfig.EqualityCheckMode,
 			},
 			},
 			Password:      f.SFTPConfig.Password.Clone(),
 			Password:      f.SFTPConfig.Password.Clone(),
 			PrivateKey:    f.SFTPConfig.PrivateKey.Clone(),
 			PrivateKey:    f.SFTPConfig.PrivateKey.Clone(),
@@ -321,9 +345,10 @@ func (f *Filesystem) GetACopy() Filesystem {
 		},
 		},
 		HTTPConfig: HTTPFsConfig{
 		HTTPConfig: HTTPFsConfig{
 			BaseHTTPFsConfig: sdk.BaseHTTPFsConfig{
 			BaseHTTPFsConfig: sdk.BaseHTTPFsConfig{
-				Endpoint:      f.HTTPConfig.Endpoint,
-				Username:      f.HTTPConfig.Username,
-				SkipTLSVerify: f.HTTPConfig.SkipTLSVerify,
+				Endpoint:          f.HTTPConfig.Endpoint,
+				Username:          f.HTTPConfig.Username,
+				SkipTLSVerify:     f.HTTPConfig.SkipTLSVerify,
+				EqualityCheckMode: f.HTTPConfig.EqualityCheckMode,
 			},
 			},
 			Password: f.HTTPConfig.Password.Clone(),
 			Password: f.HTTPConfig.Password.Clone(),
 			APIKey:   f.HTTPConfig.APIKey.Clone(),
 			APIKey:   f.HTTPConfig.APIKey.Clone(),

+ 16 - 1
internal/vfs/httpfs.go

@@ -90,7 +90,7 @@ func (c *HTTPFsConfig) setEmptyCredentialsIfNil() {
 	}
 	}
 }
 }
 
 
-func (c *HTTPFsConfig) isEqual(other *HTTPFsConfig) bool {
+func (c *HTTPFsConfig) isEqual(other HTTPFsConfig) bool {
 	if c.Endpoint != other.Endpoint {
 	if c.Endpoint != other.Endpoint {
 		return false
 		return false
 	}
 	}
@@ -108,6 +108,15 @@ func (c *HTTPFsConfig) isEqual(other *HTTPFsConfig) bool {
 	return c.APIKey.IsEqual(other.APIKey)
 	return c.APIKey.IsEqual(other.APIKey)
 }
 }
 
 
+func (c *HTTPFsConfig) isSameResource(other HTTPFsConfig) bool {
+	if c.EqualityCheckMode > 0 || other.EqualityCheckMode > 0 {
+		if c.Username != other.Username {
+			return false
+		}
+	}
+	return c.Endpoint == other.Endpoint
+}
+
 // validate returns an error if the configuration is not valid
 // validate returns an error if the configuration is not valid
 func (c *HTTPFsConfig) validate() error {
 func (c *HTTPFsConfig) validate() error {
 	c.setEmptyCredentialsIfNil()
 	c.setEmptyCredentialsIfNil()
@@ -128,6 +137,9 @@ func (c *HTTPFsConfig) validate() error {
 			return fmt.Errorf("httpfs: invalid unix domain socket path: %q", socketPath)
 			return fmt.Errorf("httpfs: invalid unix domain socket path: %q", socketPath)
 		}
 		}
 	}
 	}
+	if !isEqualityCheckModeValid(c.EqualityCheckMode) {
+		return errors.New("invalid equality_check_mode")
+	}
 	if c.Password.IsEncrypted() && !c.Password.IsValid() {
 	if c.Password.IsEncrypted() && !c.Password.IsValid() {
 		return errors.New("httpfs: invalid encrypted password")
 		return errors.New("httpfs: invalid encrypted password")
 	}
 	}
@@ -357,6 +369,9 @@ func (fs *HTTPFs) Create(name string, flag int) (File, *PipeWriter, func(), erro
 
 
 // Rename renames (moves) source to target.
 // Rename renames (moves) source to target.
 func (fs *HTTPFs) Rename(source, target string) error {
 func (fs *HTTPFs) Rename(source, target string) error {
+	if source == target {
+		return nil
+	}
 	ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
 	ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
 	defer cancelFn()
 	defer cancelFn()
 
 

+ 3 - 0
internal/vfs/osfs.go

@@ -116,6 +116,9 @@ func (*OsFs) Create(name string, flag int) (File, *PipeWriter, func(), error) {
 
 
 // Rename renames (moves) source to target
 // Rename renames (moves) source to target
 func (fs *OsFs) Rename(source, target string) error {
 func (fs *OsFs) Rename(source, target string) error {
+	if source == target {
+		return nil
+	}
 	err := os.Rename(source, target)
 	err := os.Rename(source, target)
 	if err != nil && isCrossDeviceError(err) {
 	if err != nil && isCrossDeviceError(err) {
 		fsLog(fs, logger.LevelError, "cross device error detected while renaming %#v -> %#v. Trying a copy and remove, this could take a long time",
 		fsLog(fs, logger.LevelError, "cross device error detected while renaming %#v -> %#v. Trying a copy and remove, this could take a long time",

+ 16 - 1
internal/vfs/sftpfs.go

@@ -83,7 +83,7 @@ func (c *SFTPFsConfig) setNilSecretsIfEmpty() {
 	}
 	}
 }
 }
 
 
-func (c *SFTPFsConfig) isEqual(other *SFTPFsConfig) bool {
+func (c *SFTPFsConfig) isEqual(other SFTPFsConfig) bool {
 	if c.Endpoint != other.Endpoint {
 	if c.Endpoint != other.Endpoint {
 		return false
 		return false
 	}
 	}
@@ -130,6 +130,15 @@ func (c *SFTPFsConfig) setEmptyCredentialsIfNil() {
 	}
 	}
 }
 }
 
 
+func (c *SFTPFsConfig) isSameResource(other SFTPFsConfig) bool {
+	if c.EqualityCheckMode > 0 || other.EqualityCheckMode > 0 {
+		if c.Username != other.Username {
+			return false
+		}
+	}
+	return c.Endpoint == other.Endpoint
+}
+
 // validate returns an error if the configuration is not valid
 // validate returns an error if the configuration is not valid
 func (c *SFTPFsConfig) validate() error {
 func (c *SFTPFsConfig) validate() error {
 	c.setEmptyCredentialsIfNil()
 	c.setEmptyCredentialsIfNil()
@@ -146,6 +155,9 @@ func (c *SFTPFsConfig) validate() error {
 	if c.BufferSize < 0 || c.BufferSize > 16 {
 	if c.BufferSize < 0 || c.BufferSize > 16 {
 		return errors.New("invalid buffer_size, valid range is 0-16")
 		return errors.New("invalid buffer_size, valid range is 0-16")
 	}
 	}
+	if !isEqualityCheckModeValid(c.EqualityCheckMode) {
+		return errors.New("invalid equality_check_mode")
+	}
 	if err := c.validateCredentials(); err != nil {
 	if err := c.validateCredentials(); err != nil {
 		return err
 		return err
 	}
 	}
@@ -378,6 +390,9 @@ func (fs *SFTPFs) Create(name string, flag int) (File, *PipeWriter, func(), erro
 
 
 // Rename renames (moves) source to target.
 // Rename renames (moves) source to target.
 func (fs *SFTPFs) Rename(source, target string) error {
 func (fs *SFTPFs) Rename(source, target string) error {
+	if source == target {
+		return nil
+	}
 	if err := fs.checkConnection(); err != nil {
 	if err := fs.checkConnection(); err != nil {
 		return err
 		return err
 	}
 	}

+ 39 - 7
internal/vfs/vfs.go

@@ -169,7 +169,7 @@ func (c *S3FsConfig) HideConfidentialData() {
 	}
 	}
 }
 }
 
 
-func (c *S3FsConfig) isEqual(other *S3FsConfig) bool {
+func (c *S3FsConfig) isEqual(other S3FsConfig) bool {
 	if c.Bucket != other.Bucket {
 	if c.Bucket != other.Bucket {
 		return false
 		return false
 	}
 	}
@@ -204,7 +204,7 @@ func (c *S3FsConfig) isEqual(other *S3FsConfig) bool {
 	return c.isSecretEqual(other)
 	return c.isSecretEqual(other)
 }
 }
 
 
-func (c *S3FsConfig) areMultipartFieldsEqual(other *S3FsConfig) bool {
+func (c *S3FsConfig) areMultipartFieldsEqual(other S3FsConfig) bool {
 	if c.UploadPartSize != other.UploadPartSize {
 	if c.UploadPartSize != other.UploadPartSize {
 		return false
 		return false
 	}
 	}
@@ -226,7 +226,7 @@ func (c *S3FsConfig) areMultipartFieldsEqual(other *S3FsConfig) bool {
 	return true
 	return true
 }
 }
 
 
-func (c *S3FsConfig) isSecretEqual(other *S3FsConfig) bool {
+func (c *S3FsConfig) isSecretEqual(other S3FsConfig) bool {
 	if c.AccessSecret == nil {
 	if c.AccessSecret == nil {
 		c.AccessSecret = kms.NewEmptySecret()
 		c.AccessSecret = kms.NewEmptySecret()
 	}
 	}
@@ -283,6 +283,16 @@ func (c *S3FsConfig) checkPartSizeAndConcurrency() error {
 	return nil
 	return nil
 }
 }
 
 
+func (c *S3FsConfig) isSameResource(other S3FsConfig) bool {
+	if c.Bucket != other.Bucket {
+		return false
+	}
+	if c.Endpoint != other.Endpoint {
+		return false
+	}
+	return c.Region == other.Region
+}
+
 // validate returns an error if the configuration is not valid
 // validate returns an error if the configuration is not valid
 func (c *S3FsConfig) validate() error {
 func (c *S3FsConfig) validate() error {
 	if c.AccessSecret == nil {
 	if c.AccessSecret == nil {
@@ -341,7 +351,7 @@ func (c *GCSFsConfig) ValidateAndEncryptCredentials(additionalData string) error
 	return nil
 	return nil
 }
 }
 
 
-func (c *GCSFsConfig) isEqual(other *GCSFsConfig) bool {
+func (c *GCSFsConfig) isEqual(other GCSFsConfig) bool {
 	if c.Bucket != other.Bucket {
 	if c.Bucket != other.Bucket {
 		return false
 		return false
 	}
 	}
@@ -366,6 +376,10 @@ func (c *GCSFsConfig) isEqual(other *GCSFsConfig) bool {
 	return c.Credentials.IsEqual(other.Credentials)
 	return c.Credentials.IsEqual(other.Credentials)
 }
 }
 
 
+func (c *GCSFsConfig) isSameResource(other GCSFsConfig) bool {
+	return c.Bucket == other.Bucket
+}
+
 // validate returns an error if the configuration is not valid
 // validate returns an error if the configuration is not valid
 func (c *GCSFsConfig) validate() error {
 func (c *GCSFsConfig) validate() error {
 	if c.Credentials == nil || c.AutomaticCredentials == 1 {
 	if c.Credentials == nil || c.AutomaticCredentials == 1 {
@@ -414,7 +428,7 @@ func (c *AzBlobFsConfig) HideConfidentialData() {
 	}
 	}
 }
 }
 
 
-func (c *AzBlobFsConfig) isEqual(other *AzBlobFsConfig) bool {
+func (c *AzBlobFsConfig) isEqual(other AzBlobFsConfig) bool {
 	if c.Container != other.Container {
 	if c.Container != other.Container {
 		return false
 		return false
 	}
 	}
@@ -457,7 +471,7 @@ func (c *AzBlobFsConfig) isEqual(other *AzBlobFsConfig) bool {
 	return c.isSecretEqual(other)
 	return c.isSecretEqual(other)
 }
 }
 
 
-func (c *AzBlobFsConfig) isSecretEqual(other *AzBlobFsConfig) bool {
+func (c *AzBlobFsConfig) isSecretEqual(other AzBlobFsConfig) bool {
 	if c.AccountKey == nil {
 	if c.AccountKey == nil {
 		c.AccountKey = kms.NewEmptySecret()
 		c.AccountKey = kms.NewEmptySecret()
 	}
 	}
@@ -533,6 +547,16 @@ func (c *AzBlobFsConfig) tryDecrypt() error {
 	return nil
 	return nil
 }
 }
 
 
+func (c *AzBlobFsConfig) isSameResource(other AzBlobFsConfig) bool {
+	if c.AccountName != other.AccountName {
+		return false
+	}
+	if c.Endpoint != other.Endpoint {
+		return false
+	}
+	return c.SASURL.GetPayload() == other.SASURL.GetPayload()
+}
+
 // validate returns an error if the configuration is not valid
 // validate returns an error if the configuration is not valid
 func (c *AzBlobFsConfig) validate() error {
 func (c *AzBlobFsConfig) validate() error {
 	if c.AccountKey == nil {
 	if c.AccountKey == nil {
@@ -578,7 +602,7 @@ func (c *CryptFsConfig) HideConfidentialData() {
 	}
 	}
 }
 }
 
 
-func (c *CryptFsConfig) isEqual(other *CryptFsConfig) bool {
+func (c *CryptFsConfig) isEqual(other CryptFsConfig) bool {
 	if c.Passphrase == nil {
 	if c.Passphrase == nil {
 		c.Passphrase = kms.NewEmptySecret()
 		c.Passphrase = kms.NewEmptySecret()
 	}
 	}
@@ -602,6 +626,10 @@ func (c *CryptFsConfig) ValidateAndEncryptCredentials(additionalData string) err
 	return nil
 	return nil
 }
 }
 
 
+func (c *CryptFsConfig) isSameResource(other CryptFsConfig) bool {
+	return c.Passphrase.GetPayload() == other.Passphrase.GetPayload()
+}
+
 // validate returns an error if the configuration is not valid
 // validate returns an error if the configuration is not valid
 func (c *CryptFsConfig) validate() error {
 func (c *CryptFsConfig) validate() error {
 	if c.Passphrase == nil || c.Passphrase.IsEmpty() {
 	if c.Passphrase == nil || c.Passphrase.IsEmpty() {
@@ -656,6 +684,10 @@ func (p *PipeWriter) Write(data []byte) (int, error) {
 	return p.writer.Write(data)
 	return p.writer.Write(data)
 }
 }
 
 
+func isEqualityCheckModeValid(mode int) bool {
+	return mode >= 0 || mode <= 1
+}
+
 // IsDirectory checks if a path exists and is a directory
 // IsDirectory checks if a path exists and is a directory
 func IsDirectory(fs Fs, path string) (bool, error) {
 func IsDirectory(fs Fs, path string) (bool, error) {
 	fileInfo, err := fs.Stat(path)
 	fileInfo, err := fs.Stat(path)

+ 18 - 0
openapi/openapi.yaml

@@ -4957,6 +4957,15 @@ components:
           maximum: 16
           maximum: 16
           example: 2
           example: 2
           description: The size of the buffer (in MB) to use for transfers. By enabling buffering, the reads and writes, from/to the remote SFTP server, are split in multiple concurrent requests and this allows data to be transferred at a faster rate, over high latency networks, by overlapping round-trip times. With buffering enabled, resuming uploads is not supported and a file cannot be opened for both reading and writing at the same time. 0 means disabled.
           description: The size of the buffer (in MB) to use for transfers. By enabling buffering, the reads and writes, from/to the remote SFTP server, are split in multiple concurrent requests and this allows data to be transferred at a faster rate, over high latency networks, by overlapping round-trip times. With buffering enabled, resuming uploads is not supported and a file cannot be opened for both reading and writing at the same time. 0 means disabled.
+        equality_check_mode:
+          type: integer
+          enum:
+            - 0
+            - 1
+          description: |
+             Defines how to check if this config points to the same server as another config. If different configs point to the same server the renaming between the fs configs is allowed:
+              * `0` username and endpoint must match. This is the default
+              * `1` only the endpoint must match  
     HTTPFsConfig:
     HTTPFsConfig:
       type: object
       type: object
       properties:
       properties:
@@ -4971,6 +4980,15 @@ components:
           $ref: '#/components/schemas/Secret'
           $ref: '#/components/schemas/Secret'
         skip_tls_verify:
         skip_tls_verify:
           type: boolean
           type: boolean
+        equality_check_mode:
+          type: integer
+          enum:
+            - 0
+            - 1
+          description: |
+             Defines how to check if this config points to the same server as another config. If different configs point to the same server the renaming between the fs configs is allowed:
+              * `0` username and endpoint must match. This is the default
+              * `1` only the endpoint must match
     FilesystemConfig:
     FilesystemConfig:
       type: object
       type: object
       properties:
       properties:

+ 22 - 0
templates/webadmin/fsconfig.html

@@ -481,6 +481,17 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             </div>
             </div>
         </div>
         </div>
 
 
+        <div class="form-group fsconfig fsconfig-sftpfs">
+            <div class="form-check">
+                <input type="checkbox" class="form-check-input" id="idSFTPEqualityCheckMode" aria-describedby="SFTPEqualityCheckHelpBlock"
+                    name="sftp_equality_check_mode" {{if eq .SFTPConfig.EqualityCheckMode 1}}checked{{end}}>
+                <label for="idSFTPEqualityCheckMode" class="form-check-label">Relaxed equality check mode</label>
+                <small id="SFTPEqualityCheckHelpBlock" class="form-text text-muted">
+                    Enable to consider only the endpoint to determine if different configs point to the same server. By default, both the endpoint and the username must match. Renaming between different configs is allowed if they point to the same server
+                </small>
+            </div>
+        </div>
+
         <div class="form-group row fsconfig fsconfig-httpfs">
         <div class="form-group row fsconfig fsconfig-httpfs">
             <label for="idHTTPEndpoint" class="col-sm-2 col-form-label">Endpoint</label>
             <label for="idHTTPEndpoint" class="col-sm-2 col-form-label">Endpoint</label>
             <div class="col-sm-10">
             <div class="col-sm-10">
@@ -518,6 +529,17 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 <label for="idHTTPSkipTLSVerify" class="form-check-label">Skip TLS verify</label>
                 <label for="idHTTPSkipTLSVerify" class="form-check-label">Skip TLS verify</label>
             </div>
             </div>
         </div>
         </div>
+
+        <div class="form-group fsconfig fsconfig-httpfs">
+            <div class="form-check">
+                <input type="checkbox" class="form-check-input" id="idHTTPEqualityCheckMode" aria-describedby="HTTPEqualityCheckHelpBlock"
+                    name="http_equality_check_mode" {{if eq .HTTPConfig.EqualityCheckMode 1}}checked{{end}}>
+                <label for="idHTTPEqualityCheckMode" class="form-check-label">Relaxed equality check mode</label>
+                <small id="HTTPEqualityCheckHelpBlock" class="form-text text-muted">
+                    Enable to consider only the endpoint to determine if different configs point to the same server. By default, both the endpoint and the username must match. Renaming between different configs is allowed if they point to the same server
+                </small>
+            </div>
+        </div>
     </div>
     </div>
 </div>
 </div>
 {{end}}
 {{end}}