Repository: rclone/rclone
Pull request #7887 (2024-06-02 15:46:06)

sftp: --sftp-connections to limit maximum number of connections
<!--
Thank you very much for contributing code or documentation to rclone! Please
fill out the following questions to make it easier for us to review your
changes.

You do not need to check all the boxes below all at once, feel free to take
your time and add more commits. If you're done and ready for review, please
check the last box.
-->

#### What is the purpose of this change?

I've noticed that my rclone instance opens hundreds of connections to my sftp server, sometimes hitting limits, despite default values of `--transfers` and `--checkers`. This PR ports a similar feature from the `ftp` remote to limit number of concurrent connections.

#### Was the change discussed in an issue or in the forum before?

No.

#### Checklist

- [X] I have read the [contribution guidelines](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#submitting-a-new-feature-or-bug-fix).
- [X] I have added tests for all changes in this PR if appropriate.
- [X] I have added documentation for the changes if appropriate.
- [X] All commit messages are in [house style](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#commit-messages).
- [X] I'm done, this Pull Request is ready for review :-)

Please verify whether this change makes sense, I'll add necessary documentation then. Also not sure if there is an easy way to add a test here, I don't see the `ftp` remote testing this feature.


Patch:
diff --git a/README.md b/README.md
index b3195441d..0c17cb6a8 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,7 @@ Rclone *("rsync for cloud storage")* is a command-line program to sync files and
   * Liara Object Storage [:page_facing_up:](https://rclone.org/s3/#liara-object-storage)
   * Linkbox [:page_facing_up:](https://rclone.org/linkbox)
   * Linode Object Storage [:page_facing_up:](https://rclone.org/s3/#linode)
+  * Magalu Object Storage [:page_facing_up:](https://rclone.org/s3/#magalu)
   * Mail.ru Cloud [:page_facing_up:](https://rclone.org/mailru/)
   * Memset Memstore [:page_facing_up:](https://rclone.org/swift/)
   * Mega [:page_facing_up:](https://rclone.org/mega/)
diff --git a/backend/b2/b2.go b/backend/b2/b2.go
index b019412df..9d6eea937 100644
--- a/backend/b2/b2.go
+++ b/backend/b2/b2.go
@@ -102,7 +102,7 @@ below will cause b2 to return specific errors:
   * "force_cap_exceeded"
 
 These will be set in the "X-Bz-Test-Mode" header which is documented
-in the [b2 integrations checklist](https://www.backblaze.com/b2/docs/integration_checklist.html).`,
+in the [b2 integrations checklist](https://www.backblaze.com/docs/cloud-storage-integration-checklist).`,
 			Default:  "",
 			Hide:     fs.OptionHideConfigurator,
 			Advanced: true,
@@ -244,7 +244,7 @@ See: [rclone backend lifecycle](#lifecycle) for setting lifecycles after bucket
 			Name:     config.ConfigEncoding,
 			Help:     config.ConfigEncodingHelp,
 			Advanced: true,
-			// See: https://www.backblaze.com/b2/docs/files.html
+			// See: https://www.backblaze.com/docs/cloud-storage-files
 			// Encode invalid UTF-8 bytes as json doesn't handle them properly.
 			// FIXME: allow /, but not leading, trailing or double
 			Default: (encoder.Display |
@@ -1566,7 +1566,7 @@ func (o *Object) Size() int64 {
 //
 // Make sure it is lower case.
 //
-// Remove unverified prefix - see https://www.backblaze.com/b2/docs/uploading.html
+// Remove unverified prefix - see https://www.backblaze.com/docs/cloud-storage-upload-files-with-the-native-api
 // Some tools (e.g. Cyberduck) use this
 func cleanSHA1(sha1 string) string {
 	const unverified = "unverified:"
diff --git a/backend/b2/upload.go b/backend/b2/upload.go
index 777852b1a..b47ae69dc 100644
--- a/backend/b2/upload.go
+++ b/backend/b2/upload.go
@@ -1,6 +1,6 @@
 // Upload large files for b2
 //
-// Docs - https://www.backblaze.com/b2/docs/large_files.html
+// Docs - https://www.backblaze.com/docs/cloud-storage-large-files
 
 package b2
 
diff --git a/backend/mailru/mailru.go b/backend/mailru/mailru.go
index b1cb6e775..d92a5290c 100644
--- a/backend/mailru/mailru.go
+++ b/backend/mailru/mailru.go
@@ -46,8 +46,8 @@ import (
 
 // Global constants
 const (
-	minSleepPacer   = 10 * time.Millisecond
-	maxSleepPacer   = 2 * time.Second
+	minSleepPacer   = 100 * time.Millisecond
+	maxSleepPacer   = 5 * time.Second
 	decayConstPacer = 2          // bigger for slower decay, exponential
 	metaExpirySec   = 20 * 60    // meta server expiration time
 	serverExpirySec = 3 * 60     // download server expiration time
diff --git a/backend/s3/s3.go b/backend/s3/s3.go
index a17446f9f..6f1ded7f1 100644
--- a/backend/s3/s3.go
+++ b/backend/s3/s3.go
@@ -129,6 +129,9 @@ var providerOption = fs.Option{
 	}, {
 		Value: "Linode",
 		Help:  "Linode Object Storage",
+	}, {
+		Value: "Magalu",
+		Help:  "Magalu Object Storage",
 	}, {
 		Value: "Minio",
 		Help:  "Minio Object Storage",
@@ -530,7 +533,7 @@ func init() {
 		}, {
 			Name:     "region",
 			Help:     "Region to connect to.\n\nLeave blank if you are using an S3 clone and you don't have a region.",
-			Provider: "!AWS,Alibaba,ArvanCloud,ChinaMobile,Cloudflare,IONOS,Petabox,Liara,Linode,Qiniu,RackCorp,Scaleway,Storj,Synology,TencentCOS,HuaweiOBS,IDrive",
+			Provider: "!AWS,Alibaba,ArvanCloud,ChinaMobile,Cloudflare,IONOS,Petabox,Liara,Linode,Magalu,Qiniu,RackCorp,Scaleway,Storj,Synology,TencentCOS,HuaweiOBS,IDrive",
 			Examples: []fs.OptionExample{{
 				Value: "",
 				Help:  "Use this if unsure.\nWill use v4 signatures and an empty region.",
@@ -933,6 +936,19 @@ func init() {
 				Value: "us-iad-1.linodeobjects.com",
 				Help:  "Washington, DC, (USA), us-iad-1",
 			}},
+		}, {
+			// Magalu endpoints: https://docs.magalu.cloud/docs/object-storage/how-to/copy-url
+			Name:     "endpoint",
+			Help:     "Endpoint for Magalu Object Storage API.",
+			Provider: "Magalu",
+			Examples: []fs.OptionExample{{
+				Value: "br-se1.magaluobjects.com",
+				Help:  "São Paulo, SP (BR), br-se1",
+			}, {
+				Value: "br-ne1.magaluobjects.com",
+				Help:  "Fortaleza, CE (BR), br-ne1",
+			},
+			},
 		}, {
 			// oss endpoints: https://help.aliyun.com/document_detail/31837.html
 			Name:     "endpoint",
@@ -1285,7 +1301,7 @@ func init() {
 		}, {
 			Name:     "endpoint",
 			Help:     "Endpoint for S3 API.\n\nRequired when using an S3 clone.",
-			Provider: "!AWS,ArvanCloud,IBMCOS,IDrive,IONOS,TencentCOS,HuaweiOBS,Alibaba,ChinaMobile,GCS,Liara,Linode,Scaleway,StackPath,Storj,Synology,RackCorp,Qiniu,Petabox",
+			Provider: "!AWS,ArvanCloud,IBMCOS,IDrive,IONOS,TencentCOS,HuaweiOBS,Alibaba,ChinaMobile,GCS,Liara,Linode,MagaluCloud,Scaleway,StackPath,Storj,Synology,RackCorp,Qiniu,Petabox",
 			Examples: []fs.OptionExample{{
 				Value:    "objects-us-east-1.dream.io",
 				Help:     "Dream Objects endpoint",
@@ -1394,6 +1410,14 @@ func init() {
 				Value:    "s3.ir-tbz-sh1.arvanstorage.ir",
 				Help:     "ArvanCloud Tabriz Iran (Shahriar) endpoint",
 				Provider: "ArvanCloud",
+			}, {
+				Value:    "br-se1.magaluobjects.com",
+				Help:     "Magalu BR Southeast 1 endpoint",
+				Provider: "Magalu",
+			}, {
+				Value:    "br-se1.magaluobjects.com",
+				Help:     "Magalu BR Northest 1 endpoint",
+				Provider: "Magalu",
 			}},
 		}, {
 			Name:     "location_constraint",
@@ -1776,7 +1800,7 @@ func init() {
 		}, {
 			Name:     "location_constraint",
 			Help:     "Location constraint - must be set to match the Region.\n\nLeave blank if not sure. Used when creating buckets only.",
-			Provider: "!AWS,Alibaba,ArvanCloud,HuaweiOBS,ChinaMobile,Cloudflare,IBMCOS,IDrive,IONOS,Leviia,Liara,Linode,Qiniu,RackCorp,Scaleway,StackPath,Storj,TencentCOS,Petabox",
+			Provider: "!AWS,Alibaba,ArvanCloud,HuaweiOBS,ChinaMobile,Cloudflare,IBMCOS,IDrive,IONOS,Leviia,Liara,Linode,Magalu,Qiniu,RackCorp,Scaleway,StackPath,Storj,TencentCOS,Petabox",
 		}, {
 			Name: "acl",
 			Help: `Canned ACL used when creating buckets and storing or copying objects.
@@ -2031,6 +2055,15 @@ If you leave it blank, this is calculated automatically from the sse_customer_ke
 				Value: "STANDARD",
 				Help:  "Standard storage class",
 			}},
+		}, {
+			// Mapping from here: #todo
+			Name:     "storage_class",
+			Help:     "The storage class to use when storing new objects in Magalu.",
+			Provider: "Magalu",
+			Examples: []fs.OptionExample{{
+				Value: "STANDARD",
+				Help:  "Standard storage class",
+			}},
 		}, {
 			// Mapping from here: https://intl.cloud.tencent.com/document/product/436/30925
 			Name:     "storage_class",
@@ -3127,7 +3160,7 @@ func setQuirks(opt *Options) {
 		listObjectsV2 = false
 		virtualHostStyle = false
 		urlEncodeListings = false
-		useAlreadyExists = false // untested
+		useAlreadyExists = true
 	case "ChinaMobile":
 		listObjectsV2 = false
 		virtualHostStyle = false
@@ -3173,6 +3206,12 @@ func setQuirks(opt *Options) {
 	case "LyveCloud":
 		useMultipartEtag = false // LyveCloud seems to calculate multipart Etags differently from AWS
 		useAlreadyExists = false // untested
+	case "Magalu":
+		listObjectsV2 = false
+		virtualHostStyle = false
+		urlEncodeListings = false
+		useMultipartEtag = false
+		useAlreadyExists = false
 	case "Minio":
 		virtualHostStyle = false
 	case "Netease":
diff --git a/backend/sftp/sftp.go b/backend/sftp/sftp.go
index 474163866..7a7c37e8f 100644
--- a/backend/sftp/sftp.go
+++ b/backend/sftp/sftp.go
@@ -332,6 +332,25 @@ cost of using more memory.
 `,
 			Default:  64,
 			Advanced: true,
+		}, {
+			Name: "connections",
+			Help: strings.Replace(`Maximum number of SFTP simultaneous connections, 0 for unlimited.
+
+Note that setting this is very likely to cause deadlocks so it should
+be used with care.
+
+If you are doing a sync or copy then make sure concurrency is one more
+than the sum of |--transfers| and |--checkers|.
+
+If you use |--check-first| then it just needs to be one more than the
+maximum of |--checkers| and |--transfers|.
+
+So for |concurrency 3| you'd use |--checkers 2 --transfers 2
+--check-first| or |--checkers 1 --transfers 1|.
+
+`, "|", "`", -1),
+			Default:  0,
+			Advanced: true,
 		}, {
 			Name:    "set_env",
 			Default: fs.SpaceSepList{},
@@ -502,6 +521,7 @@ type Options struct {
 	IdleTimeout             fs.Duration     `config:"idle_timeout"`
 	ChunkSize               fs.SizeSuffix   `config:"chunk_size"`
 	Concurrency             int             `config:"concurrency"`
+	Connections             int             `config:"connections"`
 	SetEnv                  fs.SpaceSepList `config:"set_env"`
 	Ciphers                 fs.SpaceSepList `config:"ciphers"`
 	KeyExchange             fs.SpaceSepList `config:"key_exchange"`
@@ -533,6 +553,7 @@ type Fs struct {
 	pacer        *fs.Pacer   // pacer for operations
 	savedpswd    string
 	sessions     atomic.Int32 // count in use sessions
+	tokens       *pacer.TokenDispenser
 }
 
 // Object is a remote SFTP file that has been stat'd (so it exists, but is not necessarily open for reading)
@@ -695,6 +716,9 @@ func (f *Fs) newSftpClient(client sshClient, opts ...sftp.ClientOption) (*sftp.C
 // Get an SFTP connection from the pool, or open a new one
 func (f *Fs) getSftpConnection(ctx context.Context) (c *conn, err error) {
 	accounting.LimitTPS(ctx)
+	if f.opt.Connections > 0 {
+		f.tokens.Get()
+	}
 	f.poolMu.Lock()
 	for len(f.pool) > 0 {
 		c = f.pool[0]
@@ -717,6 +741,9 @@ func (f *Fs) getSftpConnection(ctx context.Context) (c *conn, err error) {
 		}
 		return false, nil
 	})
+	if f.opt.Connections > 0 && c == nil {
+		f.tokens.Put()
+	}
 	return c, err
 }
 
@@ -727,6 +754,9 @@ func (f *Fs) getSftpConnection(ctx context.Context) (c *conn, err error) {
 // if err is not nil then it checks the connection is alive using a
 // Getwd request
 func (f *Fs) putSftpConnection(pc **conn, err error) {
+	if f.opt.Connections > 0 {
+		defer f.tokens.Put()
+	}
 	c := *pc
 	if !c.sshClient.CanReuse() {
 		return
@@ -812,6 +842,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 	if len(opt.SSH) != 0 && ((opt.User != currentUser && opt.User != "") || opt.Host != "" || (opt.Port != "22" && opt.Port != "")) {
 		fs.Logf(name, "--sftp-ssh is in use - ignoring user/host/port from config - set in the parameters to --sftp-ssh (remove them from the config to silence this warning)")
 	}
+	f.tokens = pacer.NewTokenDispenser(opt.Connections)
 
 	if opt.User == "" {
 		opt.User = currentUser
diff --git a/backend/ulozto/ulozto.go b/backend/ulozto/ulozto.go
index 1cdeefd74..d39435aef 100644
--- a/backend/ulozto/ulozto.go
+++ b/backend/ulozto/ulozto.go
@@ -1020,7 +1020,7 @@ func (f *Fs) CreateDir(ctx context.Context, parentSlug, leaf string) (newID stri
 	return folder.Slug, nil
 }
 
-func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *api.File) (*Object, error) {
+func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *api.File) (fs.Object, error) {
 	o := &Object{
 		fs:     f,
 		remote: remote,
diff --git a/cmd/gitannex/gitannex.go b/cmd/gitannex/gitannex.go
index 0eb09da12..b8ef8b78f 100644
--- a/cmd/gitannex/gitannex.go
+++ b/cmd/gitannex/gitannex.go
@@ -110,12 +110,32 @@ func (m *messageParser) finalParameter() string {
 // configDefinition describes a configuration value required by this command. We
 // use "GETCONFIG" messages to query git-annex for these values at runtime.
 type configDefinition struct {
-	name         string
+	names        []string
 	description  string
 	destination  *string
 	defaultValue *string
 }
 
+func (c *configDefinition) getCanonicalName() string {
+	if len(c.names) < 1 {
+		panic(fmt.Errorf("configDefinition must have at least one name: %v", c))
+	}
+	return c.names[0]
+}
+
+// fullDescription returns a single-line, human-readable description for this
+// config. The returned string begins with a list of synonyms and ends with
+// `c.description`.
+func (c *configDefinition) fullDescription() string {
+	if len(c.names) <= 1 {
+		return c.description
+	}
+	// Exclude the canonical name from the list of synonyms.
+	synonyms := c.names[1:len(c.names)]
+	commaSeparatedSynonyms := strings.Join(synonyms, ", ")
+	return fmt.Sprintf("(synonyms: %s) %s", commaSeparatedSynonyms, c.description)
+}
+
 // server contains this command's current state.
 type server struct {
 	reader *bufio.Reader
@@ -274,7 +294,7 @@ func (s *server) getRequiredConfigs() []configDefinition {
 
 	return []configDefinition{
 		{
-			"rcloneremotename",
+			[]string{"rcloneremotename", "target"},
 			"Name of the rclone remote to use. " +
 				"Must match a remote known to rclone. " +
 				"(Note that rclone remotes are a distinct concept from git-annex remotes.)",
@@ -282,7 +302,7 @@ func (s *server) getRequiredConfigs() []configDefinition {
 			nil,
 		},
 		{
-			"rcloneprefix",
+			[]string{"rcloneprefix", "prefix"},
 			"Directory where rclone will write git-annex content. " +
 				fmt.Sprintf("If not specified, defaults to %q. ", defaultRclonePrefix) +
 				"This directory will be created on init if it does not exist.",
@@ -290,7 +310,7 @@ func (s *server) getRequiredConfigs() []configDefinition {
 			&defaultRclonePrefix,
 		},
 		{
-			"rclonelayout",
+			[]string{"rclonelayout", "rclone_layout"},
 			"Defines where, within the rcloneprefix directory, rclone will write git-annex content. " +
 				fmt.Sprintf("Must be one of %v. ", allLayoutModes()) +
 				fmt.Sprintf("If empty, defaults to %q.", defaultRcloneLayout),
@@ -309,27 +329,35 @@ func (s *server) queryConfigs() error {
 	// Send a "GETCONFIG" message for each required config and parse git-annex's
 	// "VALUE" response.
 	for _, config := range s.getRequiredConfigs() {
-		s.sendMsg(fmt.Sprintf("GETCONFIG %s", config.name))
-
-		message, err := s.getMsg()
-		if err != nil {
-			return err
-		}
-
-		valueKeyword, err := message.nextSpaceDelimitedParameter()
-		if err != nil || valueKeyword != "VALUE" {
-			return fmt.Errorf("failed to parse config value: %s %s", valueKeyword, message.line)
-		}
-
-		value := message.finalParameter()
-		if value == "" && config.defaultValue == nil {
-			return fmt.Errorf("config value of %q must not be empty", config.name)
+		var valueReceived bool
+		// Try each of the config's names in sequence, starting with the
+		// canonical name.
+		for _, configName := range config.names {
+			s.sendMsg(fmt.Sprintf("GETCONFIG %s", configName))
+
+			message, err := s.getMsg()
+			if err != nil {
+				return err
+			}
+
+			valueKeyword, err := message.nextSpaceDelimitedParameter()
+			if err != nil || valueKeyword != "VALUE" {
+				return fmt.Errorf("failed to parse config value: %s %s", valueKeyword, message.line)
+			}
+
+			value := message.finalParameter()
+			if value != "" {
+				*config.destination = value
+				valueReceived = true
+				break
+			}
 		}
-		if value == "" {
+		if !valueReceived {
+			if config.defaultValue == nil {
+				return fmt.Errorf("did not receive a non-empty config value for %q", config.getCanonicalName())
+			}
 			*config.destination = *config.defaultValue
-			continue
 		}
-		*config.destination = value
 	}
 
 	s.configsDone = true
@@ -349,7 +377,7 @@ func (s *server) handlePrepare() error {
 // in sync with `handlePrepare()`.
 func (s *server) handleListConfigs() {
 	for _, config := range s.getRequiredConfigs() {
-		s.sendMsg(fmt.Sprintf("CONFIG %s %s", config.name, config.description))
+		s.sendMsg(fmt.Sprintf("CONFIG %s %s", config.getCanonicalName(), config.fullDescription()))
 	}
 	s.sendMsg("CONFIGEND")
 }
diff --git a/cmd/gitannex/gitannex.md b/cmd/gitannex/gitannex.md
index 87d0e8092..5ffa8872b 100644
--- a/cmd/gitannex/gitannex.md
+++ b/cmd/gitannex/gitannex.md
@@ -1,49 +1,69 @@
-Rclone's gitannex subcommand enables git-annex to store and retrieve content
-from an rclone remote. It expects to be run by git-annex, not directly by users.
-It is an "external special remote program" as defined by git-annex.
+Rclone's `gitannex` subcommand enables [git-annex] to store and retrieve content
+from an rclone remote. It is meant to be run by git-annex, not directly by
+users.
+
+[git-annex]: https://git-annex.branchable.com/
 
 Installation on Linux
 ---------------------
 
-1. Create a symlink and ensure it's on your PATH. For example:
+1. Skip this step if your version of git-annex is [10.20240430] or newer.
+   Otherwise, you must create a symlink somewhere on your PATH with a particular
+   name. This symlink helps git-annex tell rclone it wants to run the "gitannex"
+   subcommand.
+
+   ```sh
+   # Create the helper symlink in "$HOME/bin".
+   ln -s "$(realpath rclone)" "$HOME/bin/git-annex-remote-rclone-builtin"
+
+   # Verify the new symlink is on your PATH.
+   which git-annex-remote-rclone-builtin
+   ```
 
-        ln -s "$(realpath rclone)" "$HOME/bin/git-annex-remote-rclone-builtin"
+   [10.20240430]: https://git-annex.branchable.com/news/version_10.20240430/
 
-2. Add a new external remote to your git-annex repo.
+2. Add a new remote to your git-annex repo. This new remote will connect
+   git-annex with the `rclone gitannex` subcommand.
 
-   The new remote's type should be "rclone-builtin". When git-annex interacts
-   with remotes of this type, it will try to run a command named
-   "git-annex-remote-rclone-builtin", so the symlink from the previous step
-   should be on your PATH.
+   Start by asking git-annex to describe the remote's available configuration
+   parameters.
 
-   * NOTE: If you are porting a remote from git-annex-remote-rclone, first
-     change the externaltype from "rclone" to "rclone-builtin". Watch out, our
-     configs have slightly different names:
+   ```sh
+   # If you skipped step 1:
+   git annex initremote MyRemote type=rclone --whatelse
 
-      | config for "rclone" | config for "rclone-builtin" |
-      |:--------------------|:----------------------------|
-      | `prefix`            | `rcloneprefix`              |
-      | `target`            | `rcloneremotename`          |
-      | `rclone_layout`     | `rclonelayout`              |
+   # If you created a symlink in step 1:
+   git annex initremote MyRemote type=external externaltype=rclone-builtin --whatelse
+    ```
+
+   > **NOTE**: If you're porting an existing [git-annex-remote-rclone] remote to
+   > use `rclone gitannex`, you can probably reuse the configuration parameters
+   > verbatim without renaming them. Check parameter synonyms with `--whatelse`
+   > as shown above.
+   >
+   > [git-annex-remote-rclone]: https://github.com/git-annex-remote-rclone/git-annex-remote-rclone
 
    The following example creates a new git-annex remote named "MyRemote" that
-   will use the rclone remote named "SomeRcloneRemote". This rclone remote must
-   be configured in your rclone.conf file, wherever that is located on your
-   system. The rcloneprefix value ensures that content is only written into the
-   rclone remote underneath the "git-annex-content" directory.
-
-        git annex initremote MyRemote         \
-            type=external                     \
-            externaltype=rclone-builtin       \
-            encryption=none                   \
-            rcloneremotename=SomeRcloneRemote \
-            rcloneprefix=git-annex-content    \
-            rclonelayout=nodir
+   will use the rclone remote named "SomeRcloneRemote". That rclone remote must
+   be one configured in your rclone.conf file, which can be located with `rclone
+   config file`.
+
+   ```sh
+   git annex initremote MyRemote         \
+       type=external                     \
+       externaltype=rclone-builtin       \
+       encryption=none                   \
+       rcloneremotename=SomeRcloneRemote \
+       rcloneprefix=git-annex-content    \
+       rclonelayout=nodir
+   ```
 
 3. Before you trust this command with your precious data, be sure to **test the
    remote**. This command is very new and has not been tested on many rclone
    backends. Caveat emptor!
 
-        git annex testremote my-rclone-remote
+   ```sh
+   git annex testremote MyRemote
+   ```
 
 Happy annexing!
diff --git a/docs/content/_index.md b/docs/content/_index.md
index 0d2b1afd2..1a535220f 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -109,7 +109,7 @@ WebDAV or S3, that work out of the box.)
 {{< provider name="Akamai Netstorage" home="https://www.akamai.com/us/en/products/media-delivery/netstorage.jsp" config="/netstorage/" >}}
 {{< provider name="Alibaba Cloud (Aliyun) Object Storage System (OSS)" home="https://www.alibabacloud.com/product/oss/" config="/s3/#alibaba-oss" >}}
 {{< provider name="Amazon S3" home="https://aws.amazon.com/s3/" config="/s3/" >}}
-{{< provider name="Backblaze B2" home="https://www.backblaze.com/b2/cloud-storage.html" config="/b2/" >}}
+{{< provider name="Backblaze B2" home="https://www.backblaze.com/cloud-storage" config="/b2/" >}}
 {{< provider name="Box" home="https://www.box.com/" config="/box/" >}}
 {{< provider name="Ceph" home="http://ceph.com/" config="/s3/#ceph" >}}
 {{< provider name="China Mobile Ecloud Elastic Object Storage (EOS)" home="https://ecloud.10086.cn/home/product-introduction/eos/" config="/s3/#china-mobile-ecloud-eos" >}}
@@ -141,6 +141,7 @@ WebDAV or S3, that work out of the box.)
 {{< provider name="Liara Object Storage" home="https://liara.ir/landing/object-storage" config="/s3/#liara-object-storage" >}}
 {{< provider name="Linkbox" home="https://linkbox.to/" config="/linkbox/" >}}
 {{< provider name="Linode Object Storage" home="https://www.linode.com/products/object-storage/" config="/s3/#linode" >}}
+{{< provider name="Magalu" home="https://magalu.cloud/object-storage/" config="/s3/#magalu" >}}
 {{< provider name="Mail.ru Cloud" home="https://cloud.mail.ru/" config="/mailru/" >}}
 {{< provider name="Memset Memstore" home="https://www.memset.com/cloud/storage/" config="/swift/" >}}
 {{< provider name="Mega" home="https://mega.nz/" config="/mega/" >}}
diff --git a/docs/content/authors.md b/docs/content/authors.md
index d6d2264fa..8a1062ad3 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -857,3 +857,7 @@ put them back in again.` >}}
   * JT Olio <jt@olio.lol>
   * Evan McBeth <64177332+AtomicRobotMan0101@users.noreply.github.com>
   * Dominik Joe Pantůček <dominik.pantucek@trustica.cz>
+  * yumeiyin <155420652+yumeiyin@users.noreply.github.com>
+  * Bruno Fernandes <54373093+folkzb@users.noreply.github.com>
+  * Thomas Schneider <tspam.github@brainfuck.space>
+  * Charles Hamilton <52973156+chamilton-ccn@users.noreply.github.com>
diff --git a/docs/content/b2.md b/docs/content/b2.md
index 695dd1ad2..51d816ed4 100644
--- a/docs/content/b2.md
+++ b/docs/content/b2.md
@@ -6,7 +6,7 @@ versionIntroduced: "v1.26"
 
 # {{< icon "fa fa-fire" >}} Backblaze B2
 
-B2 is [Backblaze's cloud storage system](https://www.backblaze.com/b2/).
+B2 is [Backblaze's cloud storage system](https://www.backblaze.com/cloud-storage).
 
 Paths are specified as `remote:bucket` (or `remote:` for the `lsd`
 command.)  You may put subdirectories in too, e.g. `remote:bucket/path/to/dir`.
@@ -77,7 +77,7 @@ excess files in the bucket.
 ### Application Keys
 
 B2 supports multiple [Application Keys for different access permission
-to B2 Buckets](https://www.backblaze.com/b2/docs/application_keys.html).
+to B2 Buckets](https://www.backblaze.com/docs/cloud-storage-application-keys).
 
 You can use these with rclone too; you will need to use rclone version 1.43
 or later.
@@ -165,7 +165,7 @@ used.
 ### Versions
 
 When rclone uploads a new version of a file it creates a [new version
-of it](https://www.backblaze.com/b2/docs/file_versions.html).
+of it](https://www.backblaze.com/docs/cloud-storage-file-versions).
 Likewise when you delete a file, the old version will be marked hidden
 and still be available.  Conversely, you may opt in to a "hard delete"
 of files with the `--b2-hard-delete` flag which would permanently remove
@@ -416,7 +416,7 @@ below will cause b2 to return specific errors:
   * "force_cap_exceeded"
 
 These will be set in the "X-Bz-Test-Mode" header which is documented
-in the [b2 integrations checklist](https://www.backblaze.com/b2/docs/integration_checklist.html).
+in the [b2 integrations checklist](https://www.backblaze.com/docs/cloud-storage-integration-checklist).
 
 Properties:
 
diff --git a/docs/content/s3.md b/docs/content/s3.md
index 1d913727b..69f53a540 100644
--- a/docs/content/s3.md
+++ b/docs/content/s3.md
@@ -25,6 +25,7 @@ The S3 backend can be used with a number of different providers:
 {{< provider name="Leviia Object Storage" home="https://www.leviia.com/object-storage/" config="/s3/#leviia" >}}
 {{< provider name="Liara Object Storage" home="https://liara.ir/landing/object-storage" config="/s3/#liara-cloud" >}}
 {{< provider name="Linode Object Storage" home="https://www.linode.com/products/object-storage/" config="/s3/#linode" >}}
+{{< provider name="Magalu Object Storage" home="https://magalu.cloud/object-storage/" config="/s3/#magalu" >}}
 {{< provider name="Minio" home="https://www.minio.io/" config="/s3/#minio" >}}
 {{< provider name="Petabox" home="https://petabox.io/" config="/s3/#petabox" >}}
 {{< provider name="Qiniu Cloud Object Storage (Kodo)" home="https://www.qiniu.com/en/products/kodo" config="/s3/#qiniu" >}}
@@ -2305,7 +2306,7 @@ Option Storage.
 Type of storage to configure.
 Choose a number from below, or type in your own value.
 ...
-XX / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, DigitalOcean, Dreamhost, Huawei OBS, IBM COS, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Synology, Tencent COS and Wasabi
+XX / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, DigitalOcean, Dreamhost, Huawei OBS, IBM COS, Lyve Cloud, Minio, Magalu, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Synology, Tencent COS and Wasabi
    \ (s3)
 ...
 Storage> s3
@@ -4389,6 +4390,123 @@ secret_access_key = SECRET_ACCESS_KEY
 endpoint = eu-central-1.linodeobjects.com
 ```
 
+### Magalu {#magalu}
+
+Here is an example of making a [Magalu Object Storage](https://magalu.cloud/object-storage/)
+configuration.  First run:
+
+    rclone config
+
+This will guide you through an interactive setup process.
+
+```
+No remotes found, make a new one?
+n) New remote
+s) Set configuration password
+q) Quit config
+n/s/q> n
+
+Enter name for new remote.
+name> magalu
+
+Option Storage.
+Type of storage to configure.
+Choose a number from below, or type in your own value.
+[snip]
+XX / Amazon S3 Compliant Storage Providers including AWS, ...Magalu, ...and others
+   \ (s3)
+[snip]
+Storage> s3
+
+Option provider.
+Choose your S3 provider.
+Choose a number from below, or type in your own value.
+Press Enter to leave empty.
+[snip]
+XX / Magalu Object Storage
+   \ (Magalu)
+[snip]
+provider> Magalu
+
+Option env_auth.
+Get AWS credentials from runtime (environment variables or EC2/ECS meta data if no env vars).
+Only applies if access_key_id and secret_access_key is blank.
+Choose a number from below, or type in your own boolean value (true or false).
+Press Enter for the default (false).
+ 1 / Enter AWS credentials in the next step.
+   \ (false)
+ 2 / Get AWS credentials from the environment (env vars or IAM).
+   \ (true)
+env_auth> 1
+
+Option access_key_id.
+AWS Access Key ID.
+Leave blank for anonymous access or runtime credentials.
+Enter a value. Press Enter to leave empty.
+access_key_id> ACCESS_KEY
+
+Option secret_access_key.
+AWS Secret Access Key (password).
+Leave blank for anonymous access or runtime credentials.
+Enter a value. Press Enter to leave empty.
+secret_access_key> SECRET_ACCESS_KEY
+
+Option endpoint.
+Endpoint for Magalu Object Storage API.
+Choose a number from below, or type in your own value.
+Press Enter to leave empty.
+ 1 / São Paulo, SP (BR), br-se1
+   \ (br-se1.magaluobjects.com)
+ 2 / Fortaleza, CE (BR), br-ne1
+   \ (br-ne1.magaluobjects.com)
+endpoint> 2
+
+Option acl.
+Canned ACL used when creating buckets and storing or copying objects.
+This ACL is used for creating objects and if bucket_acl isn't set, for creating buckets too.
+For more info visit https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl
+Note that this ACL is applied when server-side copying objects as S3
+doesn't copy the ACL from the source but rather writes a fresh one.
+If the acl is an empty string then no X-Amz-Acl: header is added and
+the default (private) will be used.
+Choose a number from below, or type in your own value.
+Press Enter to leave empty.
+   / Owner gets FULL_CONTROL.
+ 1 | No one else has access rights (default).
+   \ (private)
+[snip]
+acl> 
+
+Edit advanced config?
+y) Yes
+n) No (default)
+y/n> n
+
+Configuration complete.
+Options:
+- type: s3
+- provider: magalu
+- access_key_id: ACCESS_KEY
+- secret_access_key: SECRET_ACCESS_KEY
+- endpoint: br-ne1.magaluobjects.com
+Keep this "magalu" remote?
+y) Yes this is OK (default)
+e) Edit this remote
+d) Delete this remote
+y/e/d> y
+```
+
+This will leave the config file looking like this.
+
+```
+[magalu]
+type = s3
+provider = Magalu
+access_key_id = ACCESS_KEY
+secret_access_key = SECRET_ACCESS_KEY
+endpoint = br-ne1.magaluobjects.com
+```
+
 ### ArvanCloud {#arvan-cloud}
 
 [ArvanCloud](https://www.arvancloud.com/en/products/cloud-storage) ArvanCloud Object Storage goes beyond the limited traditional file storage. 
diff --git a/fs/operations/multithread.go b/fs/operations/multithread.go
index 24725b355..582df8445 100644
--- a/fs/operations/multithread.go
+++ b/fs/operations/multithread.go
@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"time"
 
 	"github.com/rclone/rclone/fs"
 	"github.com/rclone/rclone/fs/accounting"
@@ -253,6 +254,9 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
 				if err != nil {
 					return nil, fmt.Errorf("multi-thread copy: failed to read metadata from source object: %w", err)
 				}
+				if _, foundMeta := meta["mtime"]; !foundMeta {
+					meta.Set("mtime", src.ModTime(ctx).Format(time.RFC3339Nano))
+				}
 				err = do.SetMetadata(ctx, meta)
 				if err != nil {
 					return nil, fmt.Errorf("multi-thread copy: failed to set metadata: %w", err)
diff --git a/fs/registry.go b/fs/registry.go
index 39caca9e3..71a0b5a6c 100644
--- a/fs/registry.go
+++ b/fs/registry.go
@@ -23,6 +23,7 @@ var Registry []*RegInfo
 var optDescription = Option{
 	Name:     "description",
 	Help:     "Description of the remote.",
+	Default:  "",
 	Advanced: true,
 }
 
diff --git a/fs/sync/sync.go b/fs/sync/sync.go
index 0d1cfc6e6..67998268f 100644
--- a/fs/sync/sync.go
+++ b/fs/sync/sync.go
@@ -98,7 +98,7 @@ type syncCopyMove struct {
 
 // For keeping track of delayed modtime sets
 type setDirModTime struct {
-	src     fs.Directory // if set the metadata should be set too
+	src     fs.Directory
 	dst     fs.Directory
 	dir     string
 	modTime time.Time
@@ -1109,7 +1109,6 @@ func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Dire
 	if !s.setDirModTimeAfter && equal {
 		return nil
 	}
-	setMeta := true
 	if s.setDirModTimeAfter && equal {
 		newDst = dst
 	} else if s.copyEmptySrcDirs {
@@ -1126,7 +1125,6 @@ func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Dire
 			err = operations.Mkdir(ctx, f, dir)
 		}
 	} else {
-		setMeta = s.setDirMetadata
 		newDst = dst
 	}
 	// If we need to set modtime after and we created a dir, then save it for later
@@ -1145,14 +1143,12 @@ func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Dire
 			s.setDirModTimesMaxLevel = level
 		}
 		set := setDirModTime{
+			src:     src,
 			dst:     newDst,
 			dir:     dir,
 			modTime: src.ModTime(ctx),
 			level:   level,
 		}
-		if setMeta {
-			set.src = src
-		}
 		s.setDirModTimes = append(s.setDirModTimes, set)
 		s.setDirModTimeMu.Unlock()
 		fs.Debugf(nil, "Added delayed dir = %q, newDst=%v", dir, newDst)
@@ -1202,8 +1198,7 @@ func (s *syncCopyMove) setDelayedDirModTimes(ctx context.Context) error {
 			}
 			g.Go(func() error {
 				var err error
-				// if item.src is set must copy full metadata
-				if item.src != nil {
+				if s.setDirMetadata {
 					_, err = operations.CopyDirMetadata(gCtx, s.fdst, item.dst, item.dir, item.src)
 				} else {
 					_, err = operations.SetDirModTime(gCtx, s.fdst, item.dst, item.dir, item.modTime)
diff --git a/lib/file/file_windows.go b/lib/file/file_windows.go
index 7840f98e1..cefd4f2f6 100644
--- a/lib/file/file_windows.go
+++ b/lib/file/file_windows.go
@@ -61,7 +61,7 @@ func OpenFile(path string, mode int, perm os.FileMode) (*os.File, error) {
 	default:
 		createmode = syscall.OPEN_EXISTING
 	}
-	h, e := syscall.CreateFile(pathp, access, sharemode, nil, createmode, syscall.FILE_ATTRIBUTE_NORMAL, 0)
+	h, e := syscall.CreateFile(pathp, access, sharemode, nil, createmode, syscall.FILE_ATTRIBUTE_NORMAL|syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
 	if e != nil {
 		return nil, e
 	}


Test patch:
diff --git a/backend/b2/b2_internal_test.go b/backend/b2/b2_internal_test.go
index b922301b1..25b2b67e6 100644
--- a/backend/b2/b2_internal_test.go
+++ b/backend/b2/b2_internal_test.go
@@ -23,7 +23,7 @@ import (
 )
 
 // Test b2 string encoding
-// https://www.backblaze.com/b2/docs/string_encoding.html
+// https://www.backblaze.com/docs/cloud-storage-native-api-string-encoding
 
 var encodeTest = []struct {
 	fullyEncoded     string
diff --git a/backend/cache/cache_test.go b/backend/cache/cache_test.go
index 41ed7e48c..764a1489a 100644
--- a/backend/cache/cache_test.go
+++ b/backend/cache/cache_test.go
@@ -18,7 +18,7 @@ func TestIntegration(t *testing.T) {
 		RemoteName:                      "TestCache:",
 		NilObject:                       (*cache.Object)(nil),
 		UnimplementableFsMethods:        []string{"PublicLink", "OpenWriterAt", "OpenChunkWriter", "DirSetModTime", "MkdirMetadata"},
-		UnimplementableObjectMethods:    []string{"MimeType", "ID", "GetTier", "SetTier", "Metadata"},
+		UnimplementableObjectMethods:    []string{"MimeType", "ID", "GetTier", "SetTier", "Metadata", "SetMetadata"},
 		UnimplementableDirectoryMethods: []string{"Metadata", "SetMetadata", "SetModTime"},
 		SkipInvalidUTF8:                 true, // invalid UTF-8 confuses the cache
 	})
diff --git a/backend/drive/drive_internal_test.go b/backend/drive/drive_internal_test.go
index 838962759..a5cd90428 100644
--- a/backend/drive/drive_internal_test.go
+++ b/backend/drive/drive_internal_test.go
@@ -551,9 +551,11 @@ func (f *Fs) InternalTestQuery(t *testing.T) {
 
 			results, err := f.query(ctx, fmt.Sprintf("%strashed=false and name='%s'", parent, escapedItem))
 			require.NoError(t, err)
-			require.Len(t, results, 1)
-			assert.Len(t, results[0].Id, 33)
-			assert.Equal(t, results[0].Name, item)
+			require.True(t, len(results) > 0)
+			for _, result := range results {
+				assert.True(t, len(result.Id) > 0)
+				assert.Equal(t, result.Name, item)
+			}
 			parent = fmt.Sprintf("'%s' in parents and ", results[0].Id)
 		}
 	})
diff --git a/cmd/gitannex/gitannex_test.go b/cmd/gitannex/gitannex_test.go
index 2b023b8c0..d53a7f921 100644
--- a/cmd/gitannex/gitannex_test.go
+++ b/cmd/gitannex/gitannex_test.go
@@ -7,6 +7,7 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"sync"
 	"testing"
@@ -192,6 +193,63 @@ func TestMessageParser(t *testing.T) {
 	}
 }
 
+func TestConfigDefinitionOneName(t *testing.T) {
+	var parsed string
+	var defaultValue = "abc"
+
+	configFoo := configDefinition{
+		names:        []string{"foo"},
+		description:  "The foo config is utterly useless.",
+		destination:  &parsed,
+		defaultValue: &defaultValue,
+	}
+
+	assert.Equal(t, "foo",
+		configFoo.getCanonicalName())
+
+	assert.Equal(t,
+		configFoo.description,
+		configFoo.fullDescription())
+}
+
+func TestConfigDefinitionTwoNames(t *testing.T) {
+	var parsed string
+	var defaultValue = "abc"
+
+	configFoo := configDefinition{
+		names:        []string{"foo", "bar"},
+		description:  "The foo config is utterly useless.",
+		destination:  &parsed,
+		defaultValue: &defaultValue,
+	}
+
+	assert.Equal(t, "foo",
+		configFoo.getCanonicalName())
+
+	assert.Equal(t,
+		"(synonyms: bar) The foo config is utterly useless.",
+		configFoo.fullDescription())
+}
+
+func TestConfigDefinitionThreeNames(t *testing.T) {
+	var parsed string
+	var defaultValue = "abc"
+
+	configFoo := configDefinition{
+		names:        []string{"foo", "bar", "baz"},
+		description:  "The foo config is utterly useless.",
+		destination:  &parsed,
+		defaultValue: &defaultValue,
+	}
+
+	assert.Equal(t, "foo",
+		configFoo.getCanonicalName())
+
+	assert.Equal(t,
+		`(synonyms: bar, baz) The foo config is utterly useless.`,
+		configFoo.fullDescription())
+}
+
 type testState struct {
 	t                *testing.T
 	server           *server
@@ -224,6 +282,12 @@ func (h *testState) requireReadLineExact(line string) {
 	require.Equal(h.t, line+"\n", receivedLine)
 }
 
+func (h *testState) requireReadLine() string {
+	receivedLine, err := h.mockStdoutReader.ReadString('\n')
+	require.NoError(h.t, err)
+	return receivedLine
+}
+
 func (h *testState) requireWriteLine(line string) {
 	_, err := h.mockStdinW.Write([]byte(line + "\n"))
 	require.NoError(h.t, err)
@@ -269,6 +333,34 @@ var localBackendTestCases = []testCase{
 			require.NoError(t, h.mockStdinW.Close())
 		},
 	},
+	{
+		label: "HandlesListConfigs",
+		testProtocolFunc: func(t *testing.T, h *testState) {
+			h.preconfigureServer()
+
+			h.requireReadLineExact("VERSION 1")
+			h.requireWriteLine("INITREMOTE")
+			h.requireReadLineExact("INITREMOTE-SUCCESS")
+
+			h.requireWriteLine("LISTCONFIGS")
+
+			require.Regexp(t,
+				regexp.MustCompile(`^CONFIG rcloneremotename \(synonyms: target\) (.|\n)*$`),
+				h.requireReadLine(),
+			)
+			require.Regexp(t,
+				regexp.MustCompile(`^CONFIG rcloneprefix \(synonyms: prefix\) (.|\n)*$`),
+				h.requireReadLine(),
+			)
+			require.Regexp(t,
+				regexp.MustCompile(`^CONFIG rclonelayout \(synonyms: rclone_layout\) (.|\n)*$`),
+				h.requireReadLine(),
+			)
+			h.requireReadLineExact("CONFIGEND")
+
+			require.NoError(t, h.mockStdinW.Close())
+		},
+	},
 	{
 		label: "HandlesPrepare",
 		testProtocolFunc: func(t *testing.T, h *testState) {
@@ -297,6 +389,38 @@ var localBackendTestCases = []testCase{
 			require.NoError(t, h.mockStdinW.Close())
 		},
 	},
+	{
+		label: "HandlesPrepareWithSynonyms",
+		testProtocolFunc: func(t *testing.T, h *testState) {
+			h.requireReadLineExact("VERSION 1")
+			h.requireWriteLine("EXTENSIONS INFO") // Advertise that we support the INFO extension
+			h.requireReadLineExact("EXTENSIONS")
+
+			if !h.server.extensionInfo {
+				t.Errorf("expected INFO extension to be enabled")
+				return
+			}
+
+			h.requireWriteLine("PREPARE")
+			h.requireReadLineExact("GETCONFIG rcloneremotename")
+			// TODO check what git-annex does when asked for a config value it does not have.
+			h.requireWriteLine("VALUE")
+			h.requireReadLineExact("GETCONFIG target")
+			h.requireWriteLine("VALUE " + h.remoteName)
+
+			h.requireReadLineExact("GETCONFIG rcloneprefix")
+			h.requireWriteLine("VALUE " + h.localFsDir)
+			h.requireReadLineExact("GETCONFIG rclonelayout")
+			h.requireWriteLine("VALUE foo")
+			h.requireReadLineExact("PREPARE-SUCCESS")
+
+			require.Equal(t, h.server.configRcloneRemoteName, h.remoteName)
+			require.Equal(t, h.server.configPrefix, h.localFsDir)
+			require.True(t, h.server.configsDone)
+
+			require.NoError(t, h.mockStdinW.Close())
+		},
+	},
 	{
 		label: "HandlesPrepareAndDoesNotTrimWhitespaceFromValue",
 		testProtocolFunc: func(t *testing.T, h *testState) {
@@ -322,6 +446,8 @@ var localBackendTestCases = []testCase{
 
 			h.requireReadLineExact("GETCONFIG rclonelayout")
 			h.requireWriteLine("VALUE")
+			h.requireReadLineExact("GETCONFIG rclone_layout")
+			h.requireWriteLine("VALUE")
 
 			h.requireReadLineExact("PREPARE-SUCCESS")
 
diff --git a/fs/sync/sync_test.go b/fs/sync/sync_test.go
index 5516e5d91..35039ae7f 100644
--- a/fs/sync/sync_test.go
+++ b/fs/sync/sync_test.go
@@ -97,6 +97,10 @@ func testCopyMetadata(t *testing.T, createEmptySrcDirs bool) {
 		t.Skip("Skipping as metadata not supported")
 	}
 
+	if createEmptySrcDirs && !features.CanHaveEmptyDirectories {
+		t.Skip("Skipping as can't have empty directories")
+	}
+
 	const content = "hello metadata world!"
 	const dirPath = "metadata sub dir"
 	const emptyDirPath = "empty metadata sub dir"
diff --git a/fstest/test_all/config.yaml b/fstest/test_all/config.yaml
index 826259d6e..4395a273a 100644
--- a/fstest/test_all/config.yaml
+++ b/fstest/test_all/config.yaml
@@ -279,7 +279,7 @@ backends:
    remote:   "TestSwiftAIO:"
    fastlist: true
  - backend:  "swift"
-   remote:   "TestSwiftAIO,use_segments_container=false:"
+   remote:   "TestSwiftAIOsegments:"
    fastlist: true
  - backend:  "swift"
    remote:   "TestSwift:"
diff --git a/fstest/testserver/init.d/PORTS.md b/fstest/testserver/init.d/PORTS.md
index f8e75c4e2..5d34fb612 100644
--- a/fstest/testserver/init.d/PORTS.md
+++ b/fstest/testserver/init.d/PORTS.md
@@ -25,6 +25,7 @@ They should be bound to localhost so they are not accessible externally.
 | 28629 | TestWebdavNextcloud |
 | 28630 | TestSMB |
 | 28631 | TestFTPProftpd |
+| 28632 | TestSwiftAIOsegments |
 | 38081 | TestWebdavOwncloud |
 
 ## Non localhost tests
diff --git a/fstest/testserver/init.d/TestSwiftAIOsegments b/fstest/testserver/init.d/TestSwiftAIOsegments
new file mode 100755
index 000000000..197487bcc
--- /dev/null
+++ b/fstest/testserver/init.d/TestSwiftAIOsegments
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+set -e
+
+NAME=swift-aio-segments
+PORT=28632
+
+. $(dirname "$0")/docker.bash
+
+start() {
+    docker run --rm -d --name ${NAME} \
+           -p 127.0.0.1:${PORT}:8080 \
+           bouncestorage/swift-aio
+    
+    echo type=swift
+    echo env_auth=false
+    echo user=test:tester
+    echo key=testing
+    echo auth=http://127.0.0.1:${PORT}/auth/v1.0
+    echo use_segments_container=false
+    echo _connect=127.0.0.1:${PORT}
+}
+
+. $(dirname "$0")/run.bash