Repository: rclone/rclone
Pull request #7428 (2023-11-18 12:40:40)

Docs cleanup hash and modtime
#### What is the purpose of this change?

Consistent use of the term `modtime` and not `mod-time`.

Make backend sections regarding modification times and hashes consistent.

#### Was the change discussed in an issue or in the forum before?

<!--
Link issues and relevant forum posts here.
-->

#### Checklist

- [x] I have read the [contribution guidelines](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#submitting-a-new-feature-or-bug-fix).
- [ ] I have added tests for all changes in this PR if appropriate.
- [ ] 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 :-)



Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 60348c2b0..6e2e885ee 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,8 +1,8 @@
-# Contributing to rclone #
+# Contributing to rclone
 
 This is a short guide on how to contribute things to rclone.
 
-## Reporting a bug ##
+## Reporting a bug
 
 If you've just got a question or aren't sure if you've found a bug
 then please use the [rclone forum](https://forum.rclone.org/) instead
@@ -12,13 +12,13 @@ When filing an issue, please include the following information if
 possible as well as a description of the problem.  Make sure you test
 with the [latest beta of rclone](https://beta.rclone.org/):
 
-  * Rclone version (e.g. output from `rclone version`)
-  * Which OS you are using and how many bits (e.g. Windows 10, 64 bit)
-  * The command you were trying to run (e.g. `rclone copy /tmp remote:tmp`)
-  * A log of the command with the `-vv` flag (e.g. output from `rclone -vv copy /tmp remote:tmp`)
-    * if the log contains secrets then edit the file with a text editor first to obscure them
+- Rclone version (e.g. output from `rclone version`)
+- Which OS you are using and how many bits (e.g. Windows 10, 64 bit)
+- The command you were trying to run (e.g. `rclone copy /tmp remote:tmp`)
+- A log of the command with the `-vv` flag (e.g. output from `rclone -vv copy /tmp remote:tmp`)
+    - if the log contains secrets then edit the file with a text editor first to obscure them
 
-## Submitting a new feature or bug fix ##
+## Submitting a new feature or bug fix
 
 If you find a bug that you'd like to fix, or a new feature that you'd
 like to implement then please submit a pull request via GitHub.
@@ -73,9 +73,9 @@ This is typically enough if you made a simple bug fix, otherwise please read the
 
 Make sure you
 
-  * Add [unit tests](#testing) for a new feature.
-  * Add [documentation](#writing-documentation) for a new feature.
-  * [Commit your changes](#committing-your-changes) using the [message guideline](#commit-messages).
+- Add [unit tests](#testing) for a new feature.
+- Add [documentation](#writing-documentation) for a new feature.
+- [Commit your changes](#committing-your-changes) using the [commit message guidelines](#commit-messages).
 
 When you are done with that push your changes to GitHub:
 
@@ -88,9 +88,9 @@ Your changes will then get reviewed and you might get asked to fix some stuff. I
 
 You may sometimes be asked to [base your changes on the latest master](#basing-your-changes-on-the-latest-master) or [squash your commits](#squashing-your-commits).
 
-## Using Git and GitHub ##
+## Using Git and GitHub
 
-### Committing your changes ###
+### Committing your changes
 
 Follow the guideline for [commit messages](#commit-messages) and then:
 
@@ -107,7 +107,7 @@ You can modify the message or changes in the latest commit using:
 
 If you amend to commits that have been pushed to GitHub, then you will have to [replace your previously pushed commits](#replacing-your-previously-pushed-commits).
 
-### Replacing your previously pushed commits ###
+### Replacing your previously pushed commits
 
 Note that you are about to rewrite the GitHub history of your branch. It is good practice to involve your collaborators before modifying commits that have been pushed to GitHub.
 
@@ -115,7 +115,7 @@ Your previously pushed commits are replaced by:
 
     git push --force origin my-new-feature 
 
-### Basing your changes on the latest master ###
+### Basing your changes on the latest master
 
 To base your changes on the latest version of the [rclone master](https://github.com/rclone/rclone/tree/master) (upstream):
 
@@ -149,13 +149,21 @@ If you squash commits that have been pushed to GitHub, then you will have to [re
 
 Tip: You may like to use `git rebase -i master` if you are experienced or have a more complex situation.
 
-### GitHub Continuous Integration ###
+### GitHub Continuous Integration
 
 rclone currently uses [GitHub Actions](https://github.com/rclone/rclone/actions) to build and test the project, which should be automatically available for your fork too from the `Actions` tab in your repository.
 
-## Testing ##
+## Testing
 
-### Quick testing ###
+### Code quality tests
+
+If you install [golangci-lint](https://github.com/golangci/golangci-lint) then you can run the same tests as get run in the CI which can be very helpful.
+
+You can run them with `make check` or with `golangci-lint run ./...`.
+
+Using these tests ensures that the rclone codebase all uses the same coding standards. These tests also check for easy mistakes to make (like forgetting to check an error return).
+
+### Quick testing
 
 rclone's tests are run from the go testing framework, so at the top
 level you can run this to run all the tests.
@@ -168,7 +176,7 @@ You can also use `make`, if supported by your platform
 
 The quicktest is [automatically run by GitHub](#github-continuous-integration) when you push your branch to GitHub.
 
-### Backend testing ###
+### Backend testing
 
 rclone contains a mixture of unit tests and integration tests.
 Because it is difficult (and in some respects pointless) to test cloud
@@ -203,7 +211,7 @@ project root:
     go install github.com/rclone/rclone/fstest/test_all
     test_all -backend drive
 
-### Full integration testing ###
+### Full integration testing
 
 If you want to run all the integration tests against all the remotes,
 then change into the project root and run
@@ -218,55 +226,56 @@ The commands may require some extra go packages which you can install with
 The full integration tests are run daily on the integration test server. You can
 find the results at https://pub.rclone.org/integration-tests/
 
-## Code Organisation ##
+## Code Organisation
 
 Rclone code is organised into a small number of top level directories
 with modules beneath.
 
-  * backend - the rclone backends for interfacing to cloud providers -
-    * all - import this to load all the cloud providers
-    * ...providers
-  * bin - scripts for use while building or maintaining rclone
-  * cmd - the rclone commands
-    * all - import this to load all the commands
-    * ...commands
-  * cmdtest - end-to-end tests of commands, flags, environment variables,...
-  * docs - the documentation and website
-    * content - adjust these docs only - everything else is autogenerated
-      * command - these are auto-generated - edit the corresponding .go file
-  * fs - main rclone definitions - minimal amount of code
-    * accounting - bandwidth limiting and statistics
-    * asyncreader - an io.Reader which reads ahead
-    * config - manage the config file and flags
-    * driveletter - detect if a name is a drive letter
-    * filter - implements include/exclude filtering
-    * fserrors - rclone specific error handling
-    * fshttp - http handling for rclone
-    * fspath - path handling for rclone
-    * hash - defines rclone's hash types and functions
-    * list - list a remote
-    * log - logging facilities
-    * march - iterates directories in lock step
-    * object - in memory Fs objects
-    * operations - primitives for sync, e.g. Copy, Move
-    * sync - sync directories
-    * walk - walk a directory
-  * fstest - provides integration test framework
-    * fstests - integration tests for the backends
-    * mockdir - mocks an fs.Directory
-    * mockobject - mocks an fs.Object
-    * test_all - Runs integration tests for everything
-  * graphics - the images used in the website, etc.
-  * lib - libraries used by the backend
-    * atexit - register functions to run when rclone exits
-    * dircache - directory ID to name caching
-    * oauthutil - helpers for using oauth
-    * pacer - retries with backoff and paces operations
-    * readers - a selection of useful io.Readers
-    * rest - a thin abstraction over net/http for REST
-  * vfs - Virtual FileSystem layer for implementing rclone mount and similar
-
-## Writing Documentation ##
+- backend - the rclone backends for interfacing to cloud providers -
+    - all - import this to load all the cloud providers
+    - ...providers
+- bin - scripts for use while building or maintaining rclone
+- cmd - the rclone commands
+    - all - import this to load all the commands
+    - ...commands
+- cmdtest - end-to-end tests of commands, flags, environment variables,...
+- docs - the documentation and website
+    - content - adjust these docs only - everything else is autogenerated
+    - command - these are auto-generated - edit the corresponding .go file
+- fs - main rclone definitions - minimal amount of code
+    - accounting - bandwidth limiting and statistics
+    - asyncreader - an io.Reader which reads ahead
+    - config - manage the config file and flags
+    - driveletter - detect if a name is a drive letter
+    - filter - implements include/exclude filtering
+    - fserrors - rclone specific error handling
+    - fshttp - http handling for rclone
+    - fspath - path handling for rclone
+    - hash - defines rclone's hash types and functions
+    - list - list a remote
+    - log - logging facilities
+    - march - iterates directories in lock step
+    - object - in memory Fs objects
+    - operations - primitives for sync, e.g. Copy, Move
+    - sync - sync directories
+    - walk - walk a directory
+- fstest - provides integration test framework
+    - fstests - integration tests for the backends
+    - mockdir - mocks an fs.Directory
+    - mockobject - mocks an fs.Object
+    - test_all - Runs integration tests for everything
+- graphics - the images used in the website, etc.
+- lib - libraries used by the backend
+    - atexit - register functions to run when rclone exits
+    - dircache - directory ID to name caching
+    - oauthutil - helpers for using oauth
+    - pacer - retries with backoff and paces operations
+    - readers - a selection of useful io.Readers
+    - rest - a thin abstraction over net/http for REST
+- librclone - in memory interface to rclone's API for embedding rclone
+- vfs - Virtual FileSystem layer for implementing rclone mount and similar
+
+## Writing Documentation
 
 If you are adding a new feature then please update the documentation.
 
@@ -277,22 +286,22 @@ alphabetical order.
 If you add a new backend option/flag, then it should be documented in
 the source file in the `Help:` field.
 
-  * Start with the most important information about the option,
+- Start with the most important information about the option,
     as a single sentence on a single line.
-    * This text will be used for the command-line flag help.
-    * It will be combined with other information, such as any default value,
+    - This text will be used for the command-line flag help.
+    - It will be combined with other information, such as any default value,
       and the result will look odd if not written as a single sentence.
-    * It should end with a period/full stop character, which will be shown
+    - It should end with a period/full stop character, which will be shown
       in docs but automatically removed when producing the flag help.
-    * Try to keep it below 80 characters, to reduce text wrapping in the terminal.
-  * More details can be added in a new paragraph, after an empty line (`"\n\n"`).
-    * Like with docs generated from Markdown, a single line break is ignored
+    - Try to keep it below 80 characters, to reduce text wrapping in the terminal.
+- More details can be added in a new paragraph, after an empty line (`"\n\n"`).
+    - Like with docs generated from Markdown, a single line break is ignored
       and two line breaks creates a new paragraph.
-    * This text will be shown to the user in `rclone config`
+    - This text will be shown to the user in `rclone config`
       and in the docs (where it will be added by `make backenddocs`,
       normally run some time before next release).
-  * To create options of enumeration type use the `Examples:` field.
-    * Each example value have their own `Help:` field, but they are treated
+- To create options of enumeration type use the `Examples:` field.
+    - Each example value have their own `Help:` field, but they are treated
       a bit different than the main option help text. They will be shown
       as an unordered list, therefore a single line break is enough to
       create a new list item. Also, for enumeration texts like name of
@@ -312,12 +321,12 @@ combined unmodified with other information (such as any default value).
 Note that you can use [GitHub's online editor](https://help.github.com/en/github/managing-files-in-a-repository/editing-files-in-another-users-repository)
 for small changes in the docs which makes it very easy.
 
-## Making a release ##
+## Making a release
 
 There are separate instructions for making a release in the RELEASE.md
 file.
 
-## Commit messages ##
+## Commit messages
 
 Please make the first line of your commit message a summary of the
 change that a user (not a developer) of rclone would like to read, and
@@ -358,7 +367,7 @@ error fixing the hang.
 Fixes #1498
 ```
 
-## Adding a dependency ##
+## Adding a dependency
 
 rclone uses the [go
 modules](https://tip.golang.org/cmd/go/#hdr-Modules__module_versions__and_more)
@@ -370,7 +379,7 @@ To add a dependency `github.com/ncw/new_dependency` see the
 instructions below.  These will fetch the dependency and add it to
 `go.mod` and `go.sum`.
 
-    GO111MODULE=on go get github.com/ncw/new_dependency
+    go get github.com/ncw/new_dependency
 
 You can add constraints on that package when doing `go get` (see the
 go docs linked above), but don't unless you really need to.
@@ -378,15 +387,15 @@ go docs linked above), but don't unless you really need to.
 Please check in the changes generated by `go mod` including `go.mod`
 and `go.sum` in the same commit as your other changes.
 
-## Updating a dependency ##
+## Updating a dependency
 
 If you need to update a dependency then run
 
-    GO111MODULE=on go get -u golang.org/x/crypto
+    go get golang.org/x/crypto
 
 Check in a single commit as above.
 
-## Updating all the dependencies ##
+## Updating all the dependencies
 
 In order to update all the dependencies then run `make update`.  This
 just uses the go modules to update all the modules to their latest
@@ -395,7 +404,7 @@ stable release. Check in the changes in a single commit as above.
 This should be done early in the release cycle to pick up new versions
 of packages in time for them to get some testing.
 
-## Updating a backend ##
+## Updating a backend
 
 If you update a backend then please run the unit tests and the
 integration tests for that backend.
@@ -410,76 +419,82 @@ integration tests.
 
 The next section goes into more detail about the tests.
 
-## Writing a new backend ##
+## Writing a new backend
 
 Choose a name.  The docs here will use `remote` as an example.
 
 Note that in rclone terminology a file system backend is called a
 remote or an fs.
 
-Research
+### Research
 
-  * Look at the interfaces defined in `fs/types.go`
-  * Study one or more of the existing remotes
+- Look at the interfaces defined in `fs/types.go`
+- Study one or more of the existing remotes
 
-Getting going
+### Getting going
 
-  * Create `backend/remote/remote.go` (copy this from a similar remote)
-    * box is a good one to start from if you have a directory-based remote
-    * b2 is a good one to start from if you have a bucket-based remote
-  * Add your remote to the imports in `backend/all/all.go`
-  * HTTP based remotes are easiest to maintain if they use rclone's [lib/rest](https://pkg.go.dev/github.com/rclone/rclone/lib/rest) module, but if there is a really good go SDK then use that instead.
-  * Try to implement as many optional methods as possible as it makes the remote more usable.
-  * Use [lib/encoder](https://pkg.go.dev/github.com/rclone/rclone/lib/encoder) to make sure we can encode any path name and `rclone info` to help determine the encodings needed
-    * `rclone purge -v TestRemote:rclone-info`
-    * `rclone test info --all --remote-encoding None -vv --write-json remote.json TestRemote:rclone-info`
-    * `go run cmd/test/info/internal/build_csv/main.go -o remote.csv remote.json`
-    * open `remote.csv` in a spreadsheet and examine
+- Create `backend/remote/remote.go` (copy this from a similar remote)
+    - box is a good one to start from if you have a directory-based remote (and shows how to use the directory cache)
+    - b2 is a good one to start from if you have a bucket-based remote
+- Add your remote to the imports in `backend/all/all.go`
+- HTTP based remotes are easiest to maintain if they use rclone's [lib/rest](https://pkg.go.dev/github.com/rclone/rclone/lib/rest) module, but if there is a really good Go SDK from the provider then use that instead.
+- Try to implement as many optional methods as possible as it makes the remote more usable.
+- Use [lib/encoder](https://pkg.go.dev/github.com/rclone/rclone/lib/encoder) to make sure we can encode any path name and `rclone info` to help determine the encodings needed
+    - `rclone purge -v TestRemote:rclone-info`
+    - `rclone test info --all --remote-encoding None -vv --write-json remote.json TestRemote:rclone-info`
+    - `go run cmd/test/info/internal/build_csv/main.go -o remote.csv remote.json`
+    - open `remote.csv` in a spreadsheet and examine
 
-Important:
+### Guidelines for a speedy merge
 
-  * Please use [lib/rest](https://pkg.go.dev/github.com/rclone/rclone/lib/rest) if you are implementing a REST like backend and parsing XML/JSON in the backend. It makes maintenance much easier.
-  * If your backend is HTTP based then please use rclone's Client or Transport from [fs/fshttp](https://pkg.go.dev/github.com/rclone/rclone/fs/fshttp) - this adds features like `--dump bodies`, `--tpslimit`, `--user-agent` without you having to code anything!
+- **Do** use [lib/rest](https://pkg.go.dev/github.com/rclone/rclone/lib/rest) if you are implementing a REST like backend and parsing XML/JSON in the backend.
+- **Do** use rclone's Client or Transport from [fs/fshttp](https://pkg.go.dev/github.com/rclone/rclone/fs/fshttp) if your backend is HTTP based - this adds features like `--dump bodies`, `--tpslimit`, `--user-agent` without you having to code anything!
+- **Do** follow your example backend exactly - use the same code order, function names, layout, structure. **Don't** move stuff around and **Don't** delete the comments.
+- **Do not** split your backend up into `fs.go` and `object.go` (there are a few backends like that - don't follow them!)
+- **Do** put your API type definitions in a separate file - by preference `api/types.go`
+- **Remember** we have >50 backends to maintain so keeping them as similar as possible to each other is a high priority!
 
-Unit tests
+### Unit tests
 
-  * Create a config entry called `TestRemote` for the unit tests to use
-  * Create a `backend/remote/remote_test.go` - copy and adjust your example remote
-  * Make sure all tests pass with `go test -v`
+- Create a config entry called `TestRemote` for the unit tests to use
+- Create a `backend/remote/remote_test.go` - copy and adjust your example remote
+- Make sure all tests pass with `go test -v`
 
-Integration tests
+### Integration tests
 
-  * Add your backend to `fstest/test_all/config.yaml`
-      * Once you've done that then you can use the integration test framework from the project root:
-      * go install ./...
-      * test_all -backends remote
+- Add your backend to `fstest/test_all/config.yaml`
+    - Once you've done that then you can use the integration test framework from the project root:
+    - go install ./...
+    - test_all -backends remote
 
 Or if you want to run the integration tests manually:
 
-  * Make sure integration tests pass with
-      * `cd fs/operations`
-      * `go test -v -remote TestRemote:`
-      * `cd fs/sync`
-      * `go test -v -remote TestRemote:`
-  * If your remote defines `ListR` check with this also
-      * `go test -v -remote TestRemote: -fast-list`
+- Make sure integration tests pass with
+    - `cd fs/operations`
+    - `go test -v -remote TestRemote:`
+    - `cd fs/sync`
+    - `go test -v -remote TestRemote:`
+- If your remote defines `ListR` check with this also
+    - `go test -v -remote TestRemote: -fast-list`
 
 See the [testing](#testing) section for more information on integration tests.
 
-Add your fs to the docs - you'll need to pick an icon for it from
+### Backend documentation
+
+Add your backend to the docs - you'll need to pick an icon for it from
 [fontawesome](http://fontawesome.io/icons/).  Keep lists of remotes in
 alphabetical order of full name of remote (e.g. `drive` is ordered as
 `Google Drive`) but with the local file system last.
 
-  * `README.md` - main GitHub page
-  * `docs/content/remote.md` - main docs page (note the backend options are automatically added to this file with `make backenddocs`)
-    * make sure this has the `autogenerated options` comments in (see your reference backend docs)
-    * update them in your backend with `bin/make_backend_docs.py remote`
-  * `docs/content/overview.md` - overview docs
-  * `docs/content/docs.md` - list of remotes in config section
-  * `docs/content/_index.md` - front page of rclone.org
-  * `docs/layouts/chrome/navbar.html` - add it to the website navigation
-  * `bin/make_manual.py` - add the page to the `docs` constant
+- `README.md` - main GitHub page
+- `docs/content/remote.md` - main docs page (note the backend options are automatically added to this file with `make backenddocs`)
+    - make sure this has the `autogenerated options` comments in (see your reference backend docs)
+    - update them in your backend with `bin/make_backend_docs.py remote`
+- `docs/content/overview.md` - overview docs
+- `docs/content/docs.md` - list of remotes in config section
+- `docs/content/_index.md` - front page of rclone.org
+- `docs/layouts/chrome/navbar.html` - add it to the website navigation
+- `bin/make_manual.py` - add the page to the `docs` constant
 
 Once you've written the docs, run `make serve` and check they look OK
 in the web browser and the links (internal and external) all work.
@@ -524,13 +539,13 @@ in the names so if these fail and the provider doesn't support
 
 For an example of adding an s3 provider see [eb3082a1](https://github.com/rclone/rclone/commit/eb3082a1ebdb76d5625f14cedec3f5154a5e7b10).
 
-## Writing a plugin ##
+## Writing a plugin
 
 New features (backends, commands) can also be added "out-of-tree", through Go plugins.
 Changes will be kept in a dynamically loaded file instead of being compiled into the main binary.
 This is useful if you can't merge your changes upstream or don't want to maintain a fork of rclone.
 
-Usage
+### Usage
 
  - Naming
    - Plugins names must have the pattern `librcloneplugin_KIND_NAME.so`.
@@ -545,7 +560,7 @@ Usage
    - Plugins must be compiled against the exact version of rclone to work.
      (The rclone used during building the plugin must be the same as the source of rclone)
 
-Building
+### Building
 
 To turn your existing additions into a Go plugin, move them to an external repository
 and change the top-level package name to `main`.
diff --git a/Makefile b/Makefile
index 3579cf24c..13b92fb90 100644
--- a/Makefile
+++ b/Makefile
@@ -153,7 +153,7 @@ rcdocs: rclone
 
 install: rclone
 	install -d ${DESTDIR}/usr/bin
-	install -t ${DESTDIR}/usr/bin ${GOPATH}/bin/rclone
+	install ${GOPATH}/bin/rclone ${DESTDIR}/usr/bin
 
 clean:
 	go clean ./...
diff --git a/README.md b/README.md
index 97ed7820d..fc8c8fe57 100644
--- a/README.md
+++ b/README.md
@@ -53,12 +53,14 @@ Rclone *("rsync for cloud storage")* is a command-line program to sync files and
   * Koofr [:page_facing_up:](https://rclone.org/koofr/)
   * Leviia Object Storage [:page_facing_up:](https://rclone.org/s3/#leviia)
   * 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)
   * 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/)
   * Memory [:page_facing_up:](https://rclone.org/memory/)
   * Microsoft Azure Blob Storage [:page_facing_up:](https://rclone.org/azureblob/)
+  * Microsoft Azure Files Storage [:page_facing_up:](https://rclone.org/azurefiles/)
   * Microsoft OneDrive [:page_facing_up:](https://rclone.org/onedrive/)
   * Minio [:page_facing_up:](https://rclone.org/s3/#minio)
   * Nextcloud [:page_facing_up:](https://rclone.org/webdav/#nextcloud)
diff --git a/backend/all/all.go b/backend/all/all.go
index 791a0b879..85009d735 100644
--- a/backend/all/all.go
+++ b/backend/all/all.go
@@ -6,6 +6,7 @@ import (
 	_ "github.com/rclone/rclone/backend/alias"
 	_ "github.com/rclone/rclone/backend/amazonclouddrive"
 	_ "github.com/rclone/rclone/backend/azureblob"
+	_ "github.com/rclone/rclone/backend/azurefiles"
 	_ "github.com/rclone/rclone/backend/b2"
 	_ "github.com/rclone/rclone/backend/box"
 	_ "github.com/rclone/rclone/backend/cache"
@@ -27,6 +28,7 @@ import (
 	_ "github.com/rclone/rclone/backend/internetarchive"
 	_ "github.com/rclone/rclone/backend/jottacloud"
 	_ "github.com/rclone/rclone/backend/koofr"
+	_ "github.com/rclone/rclone/backend/linkbox"
 	_ "github.com/rclone/rclone/backend/local"
 	_ "github.com/rclone/rclone/backend/mailru"
 	_ "github.com/rclone/rclone/backend/mega"
diff --git a/backend/azurefiles/azurefiles.go b/backend/azurefiles/azurefiles.go
new file mode 100644
index 000000000..fd8b4a06e
--- /dev/null
+++ b/backend/azurefiles/azurefiles.go
@@ -0,0 +1,1367 @@
+//go:build !plan9 && !js
+// +build !plan9,!js
+
+// Package azurefiles provides an interface to Microsoft Azure Files
+package azurefiles
+
+/*
+   TODO
+
+   This uses LastWriteTime which seems to work. The API return also
+   has LastModified - needs investigation
+
+   Needs pacer to have retries
+
+   HTTP headers need to be passed
+
+   Could support Metadata
+
+   FIXME write mime type
+
+   See FIXME markers
+
+   Optional interfaces for Object
+   - ID
+
+*/
+
+import (
+	"bytes"
+	"context"
+	"crypto/md5"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
+	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azfile/directory"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azfile/file"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azfile/fileerror"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azfile/service"
+	"github.com/Azure/azure-sdk-for-go/sdk/storage/azfile/share"
+	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fs/config"
+	"github.com/rclone/rclone/fs/config/configmap"
+	"github.com/rclone/rclone/fs/config/configstruct"
+	"github.com/rclone/rclone/fs/config/obscure"
+	"github.com/rclone/rclone/fs/fshttp"
+	"github.com/rclone/rclone/fs/hash"
+	"github.com/rclone/rclone/lib/encoder"
+	"github.com/rclone/rclone/lib/env"
+	"github.com/rclone/rclone/lib/readers"
+)
+
+const (
+	maxFileSize           = 4 * fs.Tebi
+	defaultChunkSize      = 4 * fs.Mebi
+	storageDefaultBaseURL = "core.windows.net" // FIXME not sure this is correct
+)
+
+func init() {
+	fs.Register(&fs.RegInfo{
+		Name:        "azurefiles",
+		Description: "Microsoft Azure Files",
+		NewFs:       NewFs,
+		Options: []fs.Option{{
+			Name: "account",
+			Help: `Azure Storage Account Name.
+
+Set this to the Azure Storage Account Name in use.
+
+Leave blank to use SAS URL or connection string, otherwise it needs to be set.
+
+If this is blank and if env_auth is set it will be read from the
+environment variable ` + "`AZURE_STORAGE_ACCOUNT_NAME`" + ` if possible.
+`,
+			Sensitive: true,
+		}, {
+			Name: "share_name",
+			Help: `Azure Files Share Name.
+
+This is required and is the name of the share to access.
+`,
+		}, {
+			Name: "env_auth",
+			Help: `Read credentials from runtime (environment variables, CLI or MSI).
+
+See the [authentication docs](/azurefiles#authentication) for full info.`,
+			Default: false,
+		}, {
+			Name: "key",
+			Help: `Storage Account Shared Key.
+
+Leave blank to use SAS URL or connection string.`,
+			Sensitive: true,
+		}, {
+			Name: "sas_url",
+			Help: `SAS URL.
+
+Leave blank if using account/key or connection string.`,
+			Sensitive: true,
+		}, {
+			Name:      "connection_string",
+			Help:      `Azure Files Connection String.`,
+			Sensitive: true,
+		}, {
+			Name: "tenant",
+			Help: `ID of the service principal's tenant. Also called its directory ID.
+
+Set this if using
+- Service principal with client secret
+- Service principal with certificate
+- User with username and password
+`,
+			Sensitive: true,
+		}, {
+			Name: "client_id",
+			Help: `The ID of the client in use.
+
+Set this if using
+- Service principal with client secret
+- Service principal with certificate
+- User with username and password
+`,
+			Sensitive: true,
+		}, {
+			Name: "client_secret",
+			Help: `One of the service principal's client secrets
+
+Set this if using
+- Service principal with client secret
+`,
+			Sensitive: true,
+		}, {
+			Name: "client_certificate_path",
+			Help: `Path to a PEM or PKCS12 certificate file including the private key.
+
+Set this if using
+- Service principal with certificate
+`,
+		}, {
+			Name: "client_certificate_password",
+			Help: `Password for the certificate file (optional).
+
+Optionally set this if using
+- Service principal with certificate
+
+And the certificate has a password.
+`,
+			IsPassword: true,
+		}, {
+			Name: "client_send_certificate_chain",
+			Help: `Send the certificate chain when using certificate auth.
+
+Specifies whether an authentication request will include an x5c header
+to support subject name / issuer based authentication. When set to
+true, authentication requests include the x5c header.
+
+Optionally set this if using
+- Service principal with certificate
+`,
+			Default:  false,
+			Advanced: true,
+		}, {
+			Name: "username",
+			Help: `User name (usually an email address)
+
+Set this if using
+- User with username and password
+`,
+			Advanced:  true,
+			Sensitive: true,
+		}, {
+			Name: "password",
+			Help: `The user's password
+
+Set this if using
+- User with username and password
+`,
+			IsPassword: true,
+			Advanced:   true,
+		}, {
+			Name: "service_principal_file",
+			Help: `Path to file containing credentials for use with a service principal.
+
+Leave blank normally. Needed only if you want to use a service principal instead of interactive login.
+
+    $ az ad sp create-for-rbac --name "<name>" \
+      --role "Storage Files Data Owner" \
+      --scopes "/subscriptions/<subscription>/resourceGroups/<resource-group>/providers/Microsoft.Storage/storageAccounts/<storage-account>/blobServices/default/containers/<container>" \
+      > azure-principal.json
+
+See ["Create an Azure service principal"](https://docs.microsoft.com/en-us/cli/azure/create-an-azure-service-principal-azure-cli) and ["Assign an Azure role for access to files data"](https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad-rbac-cli) pages for more details.
+
+**NB** this section needs updating for Azure Files - pull requests appreciated!
+
+It may be more convenient to put the credentials directly into the
+rclone config file under the ` + "`client_id`, `tenant` and `client_secret`" + `
+keys instead of setting ` + "`service_principal_file`" + `.
+`,
+			Advanced: true,
+		}, {
+			Name: "use_msi",
+			Help: `Use a managed service identity to authenticate (only works in Azure).
+
+When true, use a [managed service identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/)
+to authenticate to Azure Storage instead of a SAS token or account key.
+
+If the VM(SS) on which this program is running has a system-assigned identity, it will
+be used by default. If the resource has no system-assigned but exactly one user-assigned identity,
+the user-assigned identity will be used by default. If the resource has multiple user-assigned
+identities, the identity to use must be explicitly specified using exactly one of the msi_object_id,
+msi_client_id, or msi_mi_res_id parameters.`,
+			Default:  false,
+			Advanced: true,
+		}, {
+			Name:      "msi_object_id",
+			Help:      "Object ID of the user-assigned MSI to use, if any.\n\nLeave blank if msi_client_id or msi_mi_res_id specified.",
+			Advanced:  true,
+			Sensitive: true,
+		}, {
+			Name:      "msi_client_id",
+			Help:      "Object ID of the user-assigned MSI to use, if any.\n\nLeave blank if msi_object_id or msi_mi_res_id specified.",
+			Advanced:  true,
+			Sensitive: true,
+		}, {
+			Name:      "msi_mi_res_id",
+			Help:      "Azure resource ID of the user-assigned MSI to use, if any.\n\nLeave blank if msi_client_id or msi_object_id specified.",
+			Advanced:  true,
+			Sensitive: true,
+		}, {
+			Name:     "endpoint",
+			Help:     "Endpoint for the service.\n\nLeave blank normally.",
+			Advanced: true,
+		}, {
+			Name: "chunk_size",
+			Help: `Upload chunk size.
+
+Note that this is stored in memory and there may be up to
+"--transfers" * "--azurefile-upload-concurrency" chunks stored at once
+in memory.`,
+			Default:  defaultChunkSize,
+			Advanced: true,
+		}, {
+			Name: "upload_concurrency",
+			Help: `Concurrency for multipart uploads.
+
+This is the number of chunks of the same file that are uploaded
+concurrently.
+
+If you are uploading small numbers of large files over high-speed
+links and these uploads do not fully utilize your bandwidth, then
+increasing this may help to speed up the transfers.
+
+Note that chunks are stored in memory and there may be up to
+"--transfers" * "--azurefile-upload-concurrency" chunks stored at once
+in memory.`,
+			Default:  16,
+			Advanced: true,
+		}, {
+			Name: "max_stream_size",
+			Help: strings.ReplaceAll(`Max size for streamed files.
+
+Azure files needs to know in advance how big the file will be. When
+rclone doesn't know it uses this value instead.
+
+This will be used when rclone is streaming data, the most common uses are:
+
+- Uploading files with |--vfs-cache-mode off| with |rclone mount|
+- Using |rclone rcat|
+- Copying files with unknown length
+
+You will need this much free space in the share as the file will be this size temporarily.
+`, "|", "`"),
+			Default:  10 * fs.Gibi,
+			Advanced: true,
+		}, {
+			Name:     config.ConfigEncoding,
+			Help:     config.ConfigEncodingHelp,
+			Advanced: true,
+			Default: (encoder.EncodeDoubleQuote |
+				encoder.EncodeBackSlash |
+				encoder.EncodeSlash |
+				encoder.EncodeColon |
+				encoder.EncodePipe |
+				encoder.EncodeLtGt |
+				encoder.EncodeAsterisk |
+				encoder.EncodeQuestion |
+				encoder.EncodeInvalidUtf8 |
+				encoder.EncodeCtl | encoder.EncodeDel |
+				encoder.EncodeDot | encoder.EncodeRightPeriod),
+		}},
+	})
+}
+
+// Options defines the configuration for this backend
+type Options struct {
+	Account                    string               `config:"account"`
+	ShareName                  string               `config:"share_name"`
+	EnvAuth                    bool                 `config:"env_auth"`
+	Key                        string               `config:"key"`
+	SASURL                     string               `config:"sas_url"`
+	ConnectionString           string               `config:"connection_string"`
+	Tenant                     string               `config:"tenant"`
+	ClientID                   string               `config:"client_id"`
+	ClientSecret               string               `config:"client_secret"`
+	ClientCertificatePath      string               `config:"client_certificate_path"`
+	ClientCertificatePassword  string               `config:"client_certificate_password"`
+	ClientSendCertificateChain bool                 `config:"client_send_certificate_chain"`
+	Username                   string               `config:"username"`
+	Password                   string               `config:"password"`
+	ServicePrincipalFile       string               `config:"service_principal_file"`
+	UseMSI                     bool                 `config:"use_msi"`
+	MSIObjectID                string               `config:"msi_object_id"`
+	MSIClientID                string               `config:"msi_client_id"`
+	MSIResourceID              string               `config:"msi_mi_res_id"`
+	Endpoint                   string               `config:"endpoint"`
+	ChunkSize                  fs.SizeSuffix        `config:"chunk_size"`
+	MaxStreamSize              fs.SizeSuffix        `config:"max_stream_size"`
+	UploadConcurrency          int                  `config:"upload_concurrency"`
+	Enc                        encoder.MultiEncoder `config:"encoding"`
+}
+
+// Fs represents a root directory inside a share. The root directory can be ""
+type Fs struct {
+	name        string            // name of this remote
+	root        string            // the path we are working on if any
+	opt         Options           // parsed config options
+	features    *fs.Features      // optional features
+	shareClient *share.Client     // a client for the share itself
+	svc         *directory.Client // the root service
+}
+
+// Object describes a Azure File Share File
+type Object struct {
+	fs          *Fs       // what this object is part of
+	remote      string    // The remote path
+	size        int64     // Size of the object
+	md5         []byte    // MD5 hash if known
+	modTime     time.Time // The modified time of the object if known
+	contentType string    // content type if known
+}
+
+// Wrap the http.Transport to satisfy the Transporter interface
+type transporter struct {
+	http.RoundTripper
+}
+
+// Make a new transporter
+func newTransporter(ctx context.Context) transporter {
+	return transporter{
+		RoundTripper: fshttp.NewTransport(ctx),
+	}
+}
+
+// Do sends the HTTP request and returns the HTTP response or error.
+func (tr transporter) Do(req *http.Request) (*http.Response, error) {
+	return tr.RoundTripper.RoundTrip(req)
+}
+
+type servicePrincipalCredentials struct {
+	AppID    string `json:"appId"`
+	Password string `json:"password"`
+	Tenant   string `json:"tenant"`
+}
+
+// parseServicePrincipalCredentials unmarshals a service principal credentials JSON file as generated by az cli.
+func parseServicePrincipalCredentials(ctx context.Context, credentialsData []byte) (*servicePrincipalCredentials, error) {
+	var spCredentials servicePrincipalCredentials
+	if err := json.Unmarshal(credentialsData, &spCredentials); err != nil {
+		return nil, fmt.Errorf("error parsing credentials from JSON file: %w", err)
+	}
+	// TODO: support certificate credentials
+	// Validate all fields present
+	if spCredentials.AppID == "" || spCredentials.Password == "" || spCredentials.Tenant == "" {
+		return nil, fmt.Errorf("missing fields in credentials file")
+	}
+	return &spCredentials, nil
+}
+
+// Factored out from NewFs so that it can be tested with opt *Options and without m configmap.Mapper
+func newFsFromOptions(ctx context.Context, name, root string, opt *Options) (fs.Fs, error) {
+	// Client options specifying our own transport
+	policyClientOptions := policy.ClientOptions{
+		Transport: newTransporter(ctx),
+	}
+	clientOpt := service.ClientOptions{
+		ClientOptions: policyClientOptions,
+	}
+
+	// Here we auth by setting one of cred, sharedKeyCred or f.client
+	var (
+		cred          azcore.TokenCredential
+		sharedKeyCred *service.SharedKeyCredential
+		client        *service.Client
+		err           error
+	)
+	switch {
+	case opt.EnvAuth:
+		// Read account from environment if needed
+		if opt.Account == "" {
+			opt.Account, _ = os.LookupEnv("AZURE_STORAGE_ACCOUNT_NAME")
+		}
+		// Read credentials from the environment
+		options := azidentity.DefaultAzureCredentialOptions{
+			ClientOptions: policyClientOptions,
+		}
+		cred, err = azidentity.NewDefaultAzureCredential(&options)
+		if err != nil {
+			return nil, fmt.Errorf("create azure environment credential failed: %w", err)
+		}
+	case opt.Account != "" && opt.Key != "":
+		sharedKeyCred, err = service.NewSharedKeyCredential(opt.Account, opt.Key)
+		if err != nil {
+			return nil, fmt.Errorf("create new shared key credential failed: %w", err)
+		}
+	case opt.SASURL != "":
+		client, err = service.NewClientWithNoCredential(opt.SASURL, &clientOpt)
+		if err != nil {
+			return nil, fmt.Errorf("unable to create SAS URL client: %w", err)
+		}
+	case opt.ConnectionString != "":
+		client, err = service.NewClientFromConnectionString(opt.ConnectionString, &clientOpt)
+		if err != nil {
+			return nil, fmt.Errorf("unable to create connection string client: %w", err)
+		}
+	case opt.ClientID != "" && opt.Tenant != "" && opt.ClientSecret != "":
+		// Service principal with client secret
+		options := azidentity.ClientSecretCredentialOptions{
+			ClientOptions: policyClientOptions,
+		}
+		cred, err = azidentity.NewClientSecretCredential(opt.Tenant, opt.ClientID, opt.ClientSecret, &options)
+		if err != nil {
+			return nil, fmt.Errorf("error creating a client secret credential: %w", err)
+		}
+	case opt.ClientID != "" && opt.Tenant != "" && opt.ClientCertificatePath != "":
+		// Service principal with certificate
+		//
+		// Read the certificate
+		data, err := os.ReadFile(env.ShellExpand(opt.ClientCertificatePath))
+		if err != nil {
+			return nil, fmt.Errorf("error reading client certificate file: %w", err)
+		}
+		// NewClientCertificateCredential requires at least one *x509.Certificate, and a
+		// crypto.PrivateKey.
+		//
+		// ParseCertificates returns these given certificate data in PEM or PKCS12 format.
+		// It handles common scenarios but has limitations, for example it doesn't load PEM
+		// encrypted private keys.
+		var password []byte
+		if opt.ClientCertificatePassword != "" {
+			pw, err := obscure.Reveal(opt.Password)
+			if err != nil {
+				return nil, fmt.Errorf("certificate password decode failed - did you obscure it?: %w", err)
+			}
+			password = []byte(pw)
+		}
+		certs, key, err := azidentity.ParseCertificates(data, password)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse client certificate file: %w", err)
+		}
+		options := azidentity.ClientCertificateCredentialOptions{
+			ClientOptions:        policyClientOptions,
+			SendCertificateChain: opt.ClientSendCertificateChain,
+		}
+		cred, err = azidentity.NewClientCertificateCredential(
+			opt.Tenant, opt.ClientID, certs, key, &options,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("create azure service principal with client certificate credential failed: %w", err)
+		}
+	case opt.ClientID != "" && opt.Tenant != "" && opt.Username != "" && opt.Password != "":
+		// User with username and password
+		options := azidentity.UsernamePasswordCredentialOptions{
+			ClientOptions: policyClientOptions,
+		}
+		password, err := obscure.Reveal(opt.Password)
+		if err != nil {
+			return nil, fmt.Errorf("user password decode failed - did you obscure it?: %w", err)
+		}
+		cred, err = azidentity.NewUsernamePasswordCredential(
+			opt.Tenant, opt.ClientID, opt.Username, password, &options,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("authenticate user with password failed: %w", err)
+		}
+	case opt.ServicePrincipalFile != "":
+		// Loading service principal credentials from file.
+		loadedCreds, err := os.ReadFile(env.ShellExpand(opt.ServicePrincipalFile))
+		if err != nil {
+			return nil, fmt.Errorf("error opening service principal credentials file: %w", err)
+		}
+		parsedCreds, err := parseServicePrincipalCredentials(ctx, loadedCreds)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing service principal credentials file: %w", err)
+		}
+		options := azidentity.ClientSecretCredentialOptions{
+			ClientOptions: policyClientOptions,
+		}
+		cred, err = azidentity.NewClientSecretCredential(parsedCreds.Tenant, parsedCreds.AppID, parsedCreds.Password, &options)
+		if err != nil {
+			return nil, fmt.Errorf("error creating a client secret credential: %w", err)
+		}
+	case opt.UseMSI:
+		// Specifying a user-assigned identity. Exactly one of the above IDs must be specified.
+		// Validate and ensure exactly one is set. (To do: better validation.)
+		var b2i = map[bool]int{false: 0, true: 1}
+		set := b2i[opt.MSIClientID != ""] + b2i[opt.MSIObjectID != ""] + b2i[opt.MSIResourceID != ""]
+		if set > 1 {
+			return nil, errors.New("more than one user-assigned identity ID is set")
+		}
+		var options azidentity.ManagedIdentityCredentialOptions
+		switch {
+		case opt.MSIClientID != "":
+			options.ID = azidentity.ClientID(opt.MSIClientID)
+		case opt.MSIObjectID != "":
+			// FIXME this doesn't appear to be in the new SDK?
+			return nil, fmt.Errorf("MSI object ID is currently unsupported")
+		case opt.MSIResourceID != "":
+			options.ID = azidentity.ResourceID(opt.MSIResourceID)
+		}
+		cred, err = azidentity.NewManagedIdentityCredential(&options)
+		if err != nil {
+			return nil, fmt.Errorf("failed to acquire MSI token: %w", err)
+		}
+	default:
+		return nil, errors.New("no authentication method configured")
+	}
+
+	// Make the client if not already created
+	if client == nil {
+		// Work out what the endpoint is if it is still unset
+		if opt.Endpoint == "" {
+			if opt.Account == "" {
+				return nil, fmt.Errorf("account must be set: can't make service URL")
+			}
+			u, err := url.Parse(fmt.Sprintf("https://%s.%s", opt.Account, storageDefaultBaseURL))
+			if err != nil {
+				return nil, fmt.Errorf("failed to make azure storage URL from account: %w", err)
+			}
+			opt.Endpoint = u.String()
+		}
+		if sharedKeyCred != nil {
+			// Shared key cred
+			client, err = service.NewClientWithSharedKeyCredential(opt.Endpoint, sharedKeyCred, &clientOpt)
+			if err != nil {
+				return nil, fmt.Errorf("create client with shared key failed: %w", err)
+			}
+		} else if cred != nil {
+			// Azidentity cred
+			client, err = service.NewClient(opt.Endpoint, cred, &clientOpt)
+			if err != nil {
+				return nil, fmt.Errorf("create client failed: %w", err)
+			}
+		}
+	}
+	if client == nil {
+		return nil, fmt.Errorf("internal error: auth failed to make credentials or client")
+	}
+
+	shareClient := client.NewShareClient(opt.ShareName)
+	svc := shareClient.NewRootDirectoryClient()
+	f := &Fs{
+		shareClient: shareClient,
+		svc:         svc,
+		name:        name,
+		root:        root,
+		opt:         *opt,
+	}
+	f.features = (&fs.Features{
+		CanHaveEmptyDirectories: true,
+		PartialUploads:          true, // files are visible as they are being uploaded
+		CaseInsensitive:         true,
+		SlowHash:                true, // calling Hash() generally takes an extra transaction
+		ReadMimeType:            true,
+		WriteMimeType:           true,
+	}).Fill(ctx, f)
+
+	// Check whether a file exists at this location
+	_, propsErr := f.fileClient("").GetProperties(ctx, nil)
+	if propsErr == nil {
+		f.root = path.Dir(root)
+		return f, fs.ErrorIsFile
+	}
+
+	return f, nil
+}
+
+// NewFs constructs an Fs from the root
+func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
+	opt := new(Options)
+	err := configstruct.Set(m, opt)
+	if err != nil {
+		return nil, err
+	}
+	return newFsFromOptions(ctx, name, root, opt)
+}
+
+// ------------------------------------------------------------
+
+// Name of the remote (as passed into NewFs)
+func (f *Fs) Name() string {
+	return f.name
+}
+
+// Root of the remote (as passed into NewFs)
+func (f *Fs) Root() string {
+	return f.root
+}
+
+// String converts this Fs to a string
+func (f *Fs) String() string {
+	return fmt.Sprintf("azurefiles root '%s'", f.root)
+}
+
+// Features returns the optional features of this Fs
+func (f *Fs) Features() *fs.Features {
+	return f.features
+}
+
+// Precision return the precision of this Fs
+//
+// One second. FileREST API times are in RFC1123 which in the example shows a precision of seconds
+// Source: https://learn.microsoft.com/en-us/rest/api/storageservices/representation-of-date-time-values-in-headers
+func (f *Fs) Precision() time.Duration {
+	return time.Second
+}
+
+// Hashes returns the supported hash sets.
+//
+// MD5: since it is listed as header in the response for file properties
+// Source: https://learn.microsoft.com/en-us/rest/api/storageservices/get-file-properties
+func (f *Fs) Hashes() hash.Set {
+	return hash.NewHashSet(hash.MD5)
+}
+
+// Encode remote and turn it into an absolute path in the share
+func (f *Fs) absPath(remote string) string {
+	return f.opt.Enc.FromStandardPath(path.Join(f.root, remote))
+}
+
+// Make a directory client from the dir
+func (f *Fs) dirClient(dir string) *directory.Client {
+	return f.svc.NewSubdirectoryClient(f.absPath(dir))
+}
+
+// Make a file client from the remote
+func (f *Fs) fileClient(remote string) *file.Client {
+	return f.svc.NewFileClient(f.absPath(remote))
+}
+
+// NewObject finds the Object at remote.  If it can't be found
+// it returns the error fs.ErrorObjectNotFound.
+//
+// Does not return ErrorIsDir when a directory exists instead of file. since the documentation
+// for [rclone.fs.Fs.NewObject] rqeuires no extra work to determine whether it is directory
+//
+// This initiates a network request and returns an error if object is not found.
+func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
+	resp, err := f.fileClient(remote).GetProperties(ctx, nil)
+	if fileerror.HasCode(err, fileerror.ParentNotFound, fileerror.ResourceNotFound) {
+		return nil, fs.ErrorObjectNotFound
+	} else if err != nil {
+		return nil, fmt.Errorf("unable to find object remote %q: %w", remote, err)
+	}
+
+	o := &Object{
+		fs:     f,
+		remote: remote,
+	}
+	o.setMetadata(&resp)
+	return o, nil
+}
+
+// Make a directory using the absolute path from the root of the share
+//
+// This recursiely creating parent directories all the way to the root
+// of the share.
+func (f *Fs) absMkdir(ctx context.Context, absPath string) error {
+	if absPath == "" {
+		return nil
+	}
+	dirClient := f.svc.NewSubdirectoryClient(absPath)
+
+	// now := time.Now()
+	// smbProps := &file.SMBProperties{
+	// 	LastWriteTime: &now,
+	// }
+	// dirCreateOptions := &directory.CreateOptions{
+	// 	FileSMBProperties: smbProps,
+	// }
+
+	_, createDirErr := dirClient.Create(ctx, nil)
+	if fileerror.HasCode(createDirErr, fileerror.ParentNotFound) {
+		parentDir := path.Dir(absPath)
+		if parentDir == absPath {
+			return fmt.Errorf("internal error: infinite recursion since parent and remote are equal")
+		}
+		makeParentErr := f.absMkdir(ctx, parentDir)
+		if makeParentErr != nil {
+			return fmt.Errorf("could not make parent of %q: %w", absPath, makeParentErr)
+		}
+		return f.absMkdir(ctx, absPath)
+	} else if fileerror.HasCode(createDirErr, fileerror.ResourceAlreadyExists) {
+		return nil
+	} else if createDirErr != nil {
+		return fmt.Errorf("unable to MkDir: %w", createDirErr)
+	}
+	return nil
+}
+
+// Mkdir creates nested directories
+func (f *Fs) Mkdir(ctx context.Context, remote string) error {
+	return f.absMkdir(ctx, f.absPath(remote))
+}
+
+// Make the parent directory of remote
+func (f *Fs) mkParentDir(ctx context.Context, remote string) error {
+	// Can't make the parent of root
+	if remote == "" {
+		return nil
+	}
+	return f.Mkdir(ctx, path.Dir(remote))
+}
+
+// Rmdir deletes the root folder
+//
+// Returns an error if it isn't empty
+func (f *Fs) Rmdir(ctx context.Context, dir string) error {
+	dirClient := f.dirClient(dir)
+	_, err := dirClient.Delete(ctx, nil)
+	if err != nil {
+		if fileerror.HasCode(err, fileerror.DirectoryNotEmpty) {
+			return fs.ErrorDirectoryNotEmpty
+		} else if fileerror.HasCode(err, fileerror.ResourceNotFound) {
+			return fs.ErrorDirNotFound
+		}
+		return fmt.Errorf("could not rmdir dir %q: %w", dir, err)
+	}
+	return nil
+}
+
+// Put the object
+//
+// Copies the reader in to the new object. This new object is returned.
+//
+// The new object may have been created if an error is returned
+func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
+	// Temporary Object under construction
+	fs := &Object{
+		fs:     f,
+		remote: src.Remote(),
+	}
+	return fs, fs.Update(ctx, in, src, options...)
+}
+
+// PutStream uploads to the remote path with the modTime given of indeterminate size
+func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
+	return f.Put(ctx, in, src, options...)
+}
+
+// List the objects and directories in dir into entries. The entries can be
+// returned in any order but should be for a complete directory.
+//
+// dir should be "" to list the root, and should not have trailing slashes.
+//
+// This should return ErrDirNotFound if the directory isn't found.
+func (f *Fs) List(ctx context.Context, dir string) (fs.DirEntries, error) {
+	var entries fs.DirEntries
+	subDirClient := f.dirClient(dir)
+
+	// Checking whether directory exists
+	_, err := subDirClient.GetProperties(ctx, nil)
+	if fileerror.HasCode(err, fileerror.ParentNotFound, fileerror.ResourceNotFound) {
+		return entries, fs.ErrorDirNotFound
+	} else if err != nil {
+		return entries, err
+	}
+
+	var opt = &directory.ListFilesAndDirectoriesOptions{
+		Include: directory.ListFilesInclude{
+			Timestamps: true,
+		},
+	}
+	pager := subDirClient.NewListFilesAndDirectoriesPager(opt)
+	for pager.More() {
+		resp, err := pager.NextPage(ctx)
+		if err != nil {
+			return entries, err
+		}
+		for _, directory := range resp.Segment.Directories {
+			// Name          *string `xml:"Name"`
+			// Attributes    *string `xml:"Attributes"`
+			// ID            *string `xml:"FileId"`
+			// PermissionKey *string `xml:"PermissionKey"`
+			// Properties.ContentLength  *int64       `xml:"Content-Length"`
+			// Properties.ChangeTime     *time.Time   `xml:"ChangeTime"`
+			// Properties.CreationTime   *time.Time   `xml:"CreationTime"`
+			// Properties.ETag           *azcore.ETag `xml:"Etag"`
+			// Properties.LastAccessTime *time.Time   `xml:"LastAccessTime"`
+			// Properties.LastModified   *time.Time   `xml:"Last-Modified"`
+			// Properties.LastWriteTime  *time.Time   `xml:"LastWriteTime"`
+			var modTime time.Time
+			if directory.Properties.LastWriteTime != nil {
+				modTime = *directory.Properties.LastWriteTime
+			}
+			leaf := f.opt.Enc.ToStandardPath(*directory.Name)
+			entry := fs.NewDir(path.Join(dir, leaf), modTime)
+			if directory.ID != nil {
+				entry.SetID(*directory.ID)
+			}
+			if directory.Properties.ContentLength != nil {
+				entry.SetSize(*directory.Properties.ContentLength)
+			}
+			entries = append(entries, entry)
+		}
+		for _, file := range resp.Segment.Files {
+			leaf := f.opt.Enc.ToStandardPath(*file.Name)
+			entry := &Object{
+				fs:     f,
+				remote: path.Join(dir, leaf),
+			}
+			if file.Properties.ContentLength != nil {
+				entry.size = *file.Properties.ContentLength
+			}
+			if file.Properties.LastWriteTime != nil {
+				entry.modTime = *file.Properties.LastWriteTime
+			}
+			entries = append(entries, entry)
+		}
+	}
+	return entries, nil
+}
+
+// ------------------------------------------------------------
+
+// Fs returns the parent Fs
+func (o *Object) Fs() fs.Info {
+	return o.fs
+}
+
+// Size of object in bytes
+func (o *Object) Size() int64 {
+	return o.size
+}
+
+// Return a string version
+func (o *Object) String() string {
+	if o == nil {
+		return "<nil>"
+	}
+	return o.remote
+}
+
+// Remote returns the remote path
+func (o *Object) Remote() string {
+	return o.remote
+}
+
+// fileClient makes a specialized client for this object
+func (o *Object) fileClient() *file.Client {
+	return o.fs.fileClient(o.remote)
+}
+
+// set the metadata from file.GetPropertiesResponse
+func (o *Object) setMetadata(resp *file.GetPropertiesResponse) {
+	if resp.ContentLength != nil {
+		o.size = *resp.ContentLength
+	}
+	o.md5 = resp.ContentMD5
+	if resp.FileLastWriteTime != nil {
+		o.modTime = *resp.FileLastWriteTime
+	}
+	if resp.ContentType != nil {
+		o.contentType = *resp.ContentType
+	}
+}
+
+// readMetaData gets the metadata if it hasn't already been fetched
+func (o *Object) getMetadata(ctx context.Context) error {
+	resp, err := o.fileClient().GetProperties(ctx, nil)
+	if err != nil {
+		return fmt.Errorf("failed to fetch properties: %w", err)
+	}
+	o.setMetadata(&resp)
+	return nil
+}
+
+// Hash returns the MD5 of an object returning a lowercase hex string
+//
+// May make a network request becaue the [fs.List] method does not
+// return MD5 hashes for DirEntry
+func (o *Object) Hash(ctx context.Context, ty hash.Type) (string, error) {
+	if ty != hash.MD5 {
+		return "", hash.ErrUnsupported
+	}
+	if len(o.md5) == 0 {
+		err := o.getMetadata(ctx)
+		if err != nil {
+			return "", err
+		}
+	}
+	return hex.EncodeToString(o.md5), nil
+}
+
+// MimeType returns the content type of the Object if
+// known, or "" if not
+func (o *Object) MimeType(ctx context.Context) string {
+	if o.contentType == "" {
+		err := o.getMetadata(ctx)
+		if err != nil {
+			fs.Errorf(o, "Failed to fetch Content-Type")
+		}
+	}
+	return o.contentType
+}
+
+// Storable returns a boolean showing whether this object storable
+func (o *Object) Storable() bool {
+	return true
+}
+
+// ModTime returns the modification time of the object
+//
+// Returns time.Now() if not present
+func (o *Object) ModTime(ctx context.Context) time.Time {
+	if o.modTime.IsZero() {
+		return time.Now()
+	}
+	return o.modTime
+}
+
+// SetModTime sets the modification time
+func (o *Object) SetModTime(ctx context.Context, t time.Time) error {
+	opt := file.SetHTTPHeadersOptions{
+		SMBProperties: &file.SMBProperties{
+			LastWriteTime: &t,
+		},
+	}
+	_, err := o.fileClient().SetHTTPHeaders(ctx, &opt)
+	if err != nil {
+		return fmt.Errorf("unable to set modTime: %w", err)
+	}
+	o.modTime = t
+	return nil
+}
+
+// Remove an object
+func (o *Object) Remove(ctx context.Context) error {
+	if _, err := o.fileClient().Delete(ctx, nil); err != nil {
+		return fmt.Errorf("unable to delete remote %q: %w", o.remote, err)
+	}
+	return nil
+}
+
+// Open an object for read
+func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
+	// Offset and Count for range download
+	var offset int64
+	var count int64
+	fs.FixRangeOption(options, o.size)
+	for _, option := range options {
+		switch x := option.(type) {
+		case *fs.RangeOption:
+			offset, count = x.Decode(o.size)
+			if count < 0 {
+				count = o.size - offset
+			}
+		case *fs.SeekOption:
+			offset = x.Offset
+		default:
+			if option.Mandatory() {
+				fs.Logf(o, "Unsupported mandatory option: %v", option)
+			}
+		}
+	}
+	opt := file.DownloadStreamOptions{
+		Range: file.HTTPRange{
+			Offset: offset,
+			Count:  count,
+		},
+	}
+	resp, err := o.fileClient().DownloadStream(ctx, &opt)
+	if err != nil {
+		return nil, fmt.Errorf("could not open remote %q: %w", o.remote, err)
+	}
+	return resp.Body, nil
+}
+
+// Returns a pointer to t - useful for returning pointers to constants
+func ptr[T any](t T) *T {
+	return &t
+}
+
+var warnStreamUpload sync.Once
+
+// Update the object with the contents of the io.Reader, modTime, size and MD5 hash
+func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
+	var (
+		size           = src.Size()
+		sizeUnknown    = false
+		hashUnknown    = true
+		fc             = o.fileClient()
+		isNewlyCreated = o.modTime.IsZero()
+		counter        *readers.CountingReader
+		md5Hash        []byte
+		hasher         = md5.New()
+	)
+
+	if size > int64(maxFileSize) {
+		return fmt.Errorf("update: max supported file size is %vB. provided size is %vB", maxFileSize, fs.SizeSuffix(size))
+	} else if size < 0 {
+		size = int64(o.fs.opt.MaxStreamSize)
+		sizeUnknown = true
+		warnStreamUpload.Do(func() {
+			fs.Logf(o.fs, "Streaming uploads will have maximum file size of %v - adjust with --azurefiles-max-stream-size", o.fs.opt.MaxStreamSize)
+		})
+	}
+
+	if isNewlyCreated {
+		// Make parent directory
+		if mkDirErr := o.fs.mkParentDir(ctx, src.Remote()); mkDirErr != nil {
+			return fmt.Errorf("update: unable to make parent directories: %w", mkDirErr)
+		}
+		// Create the file at the size given
+		if _, createErr := fc.Create(ctx, size, nil); createErr != nil {
+			return fmt.Errorf("update: unable to create file: %w", createErr)
+		}
+	} else {
+		// Resize the file if needed
+		if size != o.Size() {
+			if _, resizeErr := fc.Resize(ctx, size, nil); resizeErr != nil {
+				return fmt.Errorf("update: unable to resize while trying to update: %w ", resizeErr)
+			}
+		}
+	}
+
+	// Measure the size if it is unknown
+	if sizeUnknown {
+		counter = readers.NewCountingReader(in)
+		in = counter
+	}
+
+	// Check we have a source MD5 hash...
+	if hashStr, err := src.Hash(ctx, hash.MD5); err == nil && hashStr != "" {
+		md5Hash, err = hex.DecodeString(hashStr)
+		if err == nil {
+			hashUnknown = false
+		} else {
+			fs.Errorf(o, "internal error: decoding hex encoded md5 %q: %v", hashStr, err)
+		}
+	}
+
+	// ...if not calculate one
+	if hashUnknown {
+		in = io.TeeReader(in, hasher)
+	}
+
+	// Upload the file
+	opt := file.UploadStreamOptions{
+		ChunkSize:   int64(o.fs.opt.ChunkSize),
+		Concurrency: o.fs.opt.UploadConcurrency,
+	}
+	if err := fc.UploadStream(ctx, in, &opt); err != nil {
+		// Remove partially uploaded file on error
+		if isNewlyCreated {
+			if _, delErr := fc.Delete(ctx, nil); delErr != nil {
+				fs.Errorf(o, "failed to delete partially uploaded file: %v", delErr)
+			}
+		}
+		return fmt.Errorf("update: failed to upload stream: %w", err)
+	}
+
+	if sizeUnknown {
+		// Read the uploaded size - the file will be truncated to that size by updateSizeHashModTime
+		size = int64(counter.BytesRead())
+	}
+	if hashUnknown {
+		md5Hash = hasher.Sum(nil)
+	}
+
+	// Update the properties
+	modTime := src.ModTime(ctx)
+	contentType := fs.MimeType(ctx, src)
+	httpHeaders := file.HTTPHeaders{
+		ContentMD5:  md5Hash,
+		ContentType: &contentType,
+	}
+	// Apply upload options (also allows one to overwrite content-type)
+	for _, option := range options {
+		key, value := option.Header()
+		lowerKey := strings.ToLower(key)
+		switch lowerKey {
+		case "cache-control":
+			httpHeaders.CacheControl = &value
+		case "content-disposition":
+			httpHeaders.ContentDisposition = &value
+		case "content-encoding":
+			httpHeaders.ContentEncoding = &value
+		case "content-language":
+			httpHeaders.ContentLanguage = &value
+		case "content-type":
+			httpHeaders.ContentType = &value
+		}
+	}
+	_, err = fc.SetHTTPHeaders(ctx, &file.SetHTTPHeadersOptions{
+		FileContentLength: &size,
+		SMBProperties: &file.SMBProperties{
+			LastWriteTime: &modTime,
+		},
+		HTTPHeaders: &httpHeaders,
+	})
+	if err != nil {
+		return fmt.Errorf("update: failed to set properties: %w", err)
+	}
+
+	// Make sure Object is in sync
+	o.size = size
+	o.md5 = md5Hash
+	o.modTime = modTime
+	o.contentType = contentType
+	return nil
+}
+
+// Move src to this remote using server-side move operations.
+//
+// This is stored with the remote path given.
+//
+// It returns the destination Object and a possible error.
+//
+// Will only be called if src.Fs().Name() == f.Name()
+//
+// If it isn't possible then return fs.ErrorCantMove
+func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
+	srcObj, ok := src.(*Object)
+	if !ok {
+		fs.Debugf(src, "Can't move - not same remote type")
+		return nil, fs.ErrorCantMove
+	}
+	err := f.mkParentDir(ctx, remote)
+	if err != nil {
+		return nil, fmt.Errorf("Move: mkParentDir failed: %w", err)
+	}
+	opt := file.RenameOptions{
+		IgnoreReadOnly:  ptr(true),
+		ReplaceIfExists: ptr(true),
+	}
+	dstAbsPath := f.absPath(remote)
+	fc := srcObj.fileClient()
+	_, err = fc.Rename(ctx, dstAbsPath, &opt)
+	if err != nil {
+		return nil, fmt.Errorf("Move: Rename failed: %w", err)
+	}
+	dstObj, err := f.NewObject(ctx, remote)
+	if err != nil {
+		return nil, fmt.Errorf("Move: NewObject failed: %w", err)
+	}
+	return dstObj, nil
+}
+
+// DirMove moves src, srcRemote to this remote at dstRemote
+// using server-side move operations.
+//
+// Will only be called if src.Fs().Name() == f.Name()
+//
+// If it isn't possible then return fs.ErrorCantDirMove
+//
+// If destination exists then return fs.ErrorDirExists
+func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error {
+	dstFs := f
+	srcFs, ok := src.(*Fs)
+	if !ok {
+		fs.Debugf(srcFs, "Can't move directory - not same remote type")
+		return fs.ErrorCantDirMove
+	}
+
+	_, err := dstFs.dirClient(dstRemote).GetProperties(ctx, nil)
+	if err == nil {
+		return fs.ErrorDirExists
+	}
+	if !fileerror.HasCode(err, fileerror.ParentNotFound, fileerror.ResourceNotFound) {
+		return fmt.Errorf("DirMove: failed to get status of destination directory: %w", err)
+	}
+
+	err = dstFs.mkParentDir(ctx, dstRemote)
+	if err != nil {
+		return fmt.Errorf("DirMove: mkParentDir failed: %w", err)
+	}
+
+	opt := directory.RenameOptions{
+		IgnoreReadOnly:  ptr(false),
+		ReplaceIfExists: ptr(false),
+	}
+	dstAbsPath := dstFs.absPath(dstRemote)
+	dirClient := srcFs.dirClient(srcRemote)
+	_, err = dirClient.Rename(ctx, dstAbsPath, &opt)
+	if err != nil {
+		if fileerror.HasCode(err, fileerror.ResourceAlreadyExists) {
+			return fs.ErrorDirExists
+		}
+		return fmt.Errorf("DirMove: Rename failed: %w", err)
+	}
+	return nil
+}
+
+// Copy src to this remote using server-side copy operations.
+//
+// This is stored with the remote path given.
+//
+// It returns the destination Object and a possible error.
+//
+// Will only be called if src.Fs().Name() == f.Name()
+//
+// If it isn't possible then return fs.ErrorCantCopy
+func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
+	srcObj, ok := src.(*Object)
+	if !ok {
+		fs.Debugf(src, "Can't copy - not same remote type")
+		return nil, fs.ErrorCantCopy
+	}
+	err := f.mkParentDir(ctx, remote)
+	if err != nil {
+		return nil, fmt.Errorf("Copy: mkParentDir failed: %w", err)
+	}
+	opt := file.StartCopyFromURLOptions{
+		CopyFileSMBInfo: &file.CopyFileSMBInfo{
+			Attributes:         file.SourceCopyFileAttributes{},
+			ChangeTime:         file.SourceCopyFileChangeTime{},
+			CreationTime:       file.SourceCopyFileCreationTime{},
+			LastWriteTime:      file.SourceCopyFileLastWriteTime{},
+			PermissionCopyMode: ptr(file.PermissionCopyModeTypeSource),
+			IgnoreReadOnly:     ptr(true),
+		},
+	}
+	srcURL := srcObj.fileClient().URL()
+	fc := f.fileClient(remote)
+	_, err = fc.StartCopyFromURL(ctx, srcURL, &opt)
+	if err != nil {
+		return nil, fmt.Errorf("Copy failed: %w", err)
+	}
+	dstObj, err := f.NewObject(ctx, remote)
+	if err != nil {
+		return nil, fmt.Errorf("Copy: NewObject failed: %w", err)
+	}
+	return dstObj, nil
+}
+
+// Implementation of WriterAt
+type writerAt struct {
+	ctx  context.Context
+	f    *Fs
+	fc   *file.Client
+	mu   sync.Mutex // protects variables below
+	size int64
+}
+
+// Adaptor to add a Close method to bytes.Reader
+type bytesReaderCloser struct {
+	*bytes.Reader
+}
+
+// Close the bytesReaderCloser
+func (bytesReaderCloser) Close() error {
+	return nil
+}
+
+// WriteAt writes len(p) bytes from p to the underlying data stream
+// at offset off. It returns the number of bytes written from p (0 <= n <= len(p))
+// and any error encountered that caused the write to stop early.
+// WriteAt must return a non-nil error if it returns n < len(p).
+//
+// If WriteAt is writing to a destination with a seek offset,
+// WriteAt should not affect nor be affected by the underlying
+// seek offset.
+//
+// Clients of WriteAt can execute parallel WriteAt calls on the same
+// destination if the ranges do not overlap.
+//
+// Implementations must not retain p.
+func (w *writerAt) WriteAt(p []byte, off int64) (n int, err error) {
+	endOffset := off + int64(len(p))
+	w.mu.Lock()
+	if w.size < endOffset {
+		_, err = w.fc.Resize(w.ctx, endOffset, nil)
+		if err != nil {
+			w.mu.Unlock()
+			return 0, fmt.Errorf("WriteAt: failed to resize file: %w ", err)
+		}
+		w.size = endOffset
+	}
+	w.mu.Unlock()
+
+	in := bytesReaderCloser{bytes.NewReader(p)}
+	_, err = w.fc.UploadRange(w.ctx, off, in, nil)
+	if err != nil {
+		return 0, err
+	}
+	return len(p), nil
+}
+
+// Close the writer
+func (w *writerAt) Close() error {
+	// FIXME should we be doing something here?
+	return nil
+}
+
+// OpenWriterAt opens with a handle for random access writes
+//
+// Pass in the remote desired and the size if known.
+//
+// It truncates any existing object
+func (f *Fs) OpenWriterAt(ctx context.Context, remote string, size int64) (fs.WriterAtCloser, error) {
+	err := f.mkParentDir(ctx, remote)
+	if err != nil {
+		return nil, fmt.Errorf("OpenWriterAt: failed to create parent directory: %w", err)
+	}
+	fc := f.fileClient(remote)
+	if size < 0 {
+		size = 0
+	}
+	_, err = fc.Create(ctx, size, nil)
+	if err != nil {
+		return nil, fmt.Errorf("OpenWriterAt: unable to create file: %w", err)
+	}
+	w := &writerAt{
+		ctx:  ctx,
+		f:    f,
+		fc:   fc,
+		size: size,
+	}
+	return w, nil
+}
+
+// About gets quota information
+func (f *Fs) About(ctx context.Context) (*fs.Usage, error) {
+	stats, err := f.shareClient.GetStatistics(ctx, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read share statistics: %w", err)
+	}
+	usage := &fs.Usage{
+		Used: stats.ShareUsageBytes, // bytes in use
+	}
+	return usage, nil
+}
+
+// Check the interfaces are satisfied
+var (
+	_ fs.Fs             = &Fs{}
+	_ fs.PutStreamer    = &Fs{}
+	_ fs.Abouter        = &Fs{}
+	_ fs.Mover          = &Fs{}
+	_ fs.DirMover       = &Fs{}
+	_ fs.Copier         = &Fs{}
+	_ fs.OpenWriterAter = &Fs{}
+	_ fs.Object         = &Object{}
+	_ fs.MimeTyper      = &Object{}
+)
diff --git a/backend/azurefiles/azurefiles_unsupported.go b/backend/azurefiles/azurefiles_unsupported.go
new file mode 100644
index 000000000..1674e8f20
--- /dev/null
+++ b/backend/azurefiles/azurefiles_unsupported.go
@@ -0,0 +1,7 @@
+// Build for azurefiles for unsupported platforms to stop go complaining
+// about "no buildable Go source files "
+
+//go:build plan9 || js
+// +build plan9 js
+
+package azurefiles
diff --git a/backend/drive/drive.go b/backend/drive/drive.go
index 1674f0d42..88e2b7253 100644
--- a/backend/drive/drive.go
+++ b/backend/drive/drive.go
@@ -143,6 +143,41 @@ var (
 	_linkTemplates   map[string]*template.Template // available link types
 )
 
+// rwChoices type for fs.Bits
+type rwChoices struct{}
+
+func (rwChoices) Choices() []fs.BitsChoicesInfo {
+	return []fs.BitsChoicesInfo{
+		{Bit: uint64(rwOff), Name: "off"},
+		{Bit: uint64(rwRead), Name: "read"},
+		{Bit: uint64(rwWrite), Name: "write"},
+	}
+}
+
+// rwChoice type alias
+type rwChoice = fs.Bits[rwChoices]
+
+const (
+	rwRead rwChoice = 1 << iota
+	rwWrite
+	rwOff rwChoice = 0
+)
+
+// Examples for the options
+var rwExamples = fs.OptionExamples{{
+	Value: rwOff.String(),
+	Help:  "Do not read or write the value",
+}, {
+	Value: rwRead.String(),
+	Help:  "Read the value only",
+}, {
+	Value: rwWrite.String(),
+	Help:  "Write the value only",
+}, {
+	Value: (rwRead | rwWrite).String(),
+	Help:  "Read and Write the value.",
+}}
+
 // Parse the scopes option returning a slice of scopes
 func driveScopes(scopesString string) (scopes []string) {
 	if scopesString == "" {
@@ -250,6 +285,10 @@ func init() {
 			}
 			return nil, fmt.Errorf("unknown state %q", config.State)
 		},
+		MetadataInfo: &fs.MetadataInfo{
+			System: systemMetadataInfo,
+			Help:   `User metadata is stored in the properties field of the drive object.`,
+		},
 		Options: append(driveOAuthOptions(), []fs.Option{{
 			Name: "scope",
 			Help: "Comma separated list of scopes that rclone should use when requesting access from drive.",
@@ -639,6 +678,56 @@ having trouble with like many empty directories.
 `,
 			Advanced: true,
 			Default:  true,
+		}, {
+			Name: "metadata_owner",
+			Help: `Control whether owner should be read or written in metadata.
+
+Owner is a standard part of the file metadata so is easy to read. But it
+isn't always desirable to set the owner from the metadata.
+
+Note that you can't set the owner on Shared Drives, and that setting
+ownership will generate an email to the new owner (this can't be
+disabled), and you can't transfer ownership to someone outside your
+organization.
+`,
+			Advanced: true,
+			Default:  rwRead,
+			Examples: rwExamples,
+		}, {
+			Name: "metadata_permissions",
+			Help: `Control whether permissions should be read or written in metadata.
+
+Reading permissions metadata from files can be done quickly, but it
+isn't always desirable to set the permissions from the metadata.
+
+Note that rclone drops any inherited permissions on Shared Drives and
+any owner permission on My Drives as these are duplicated in the owner
+metadata.
+`,
+			Advanced: true,
+			Default:  rwOff,
+			Examples: rwExamples,
+		}, {
+			Name: "metadata_labels",
+			Help: `Control whether labels should be read or written in metadata.
+
+Reading labels metadata from files takes an extra API transaction and
+will slow down listings. It isn't always desirable to set the labels
+from the metadata.
+
+The format of labels is documented in the drive API documentation at
+https://developers.google.com/drive/api/reference/rest/v3/Label -
+rclone just provides a JSON dump of this format.
+
+When setting labels, the label and fields must already exist - rclone
+will not create them. This means that if you are transferring labels
+from two different accounts you will have to create the labels in
+advance and use the metadata mapper to translate the IDs between the
+two accounts.
+`,
+			Advanced: true,
+			Default:  rwOff,
+			Examples: rwExamples,
 		}, {
 			Name:     config.ConfigEncoding,
 			Help:     config.ConfigEncodingHelp,
@@ -715,6 +804,9 @@ type Options struct {
 	SkipDanglingShortcuts     bool                 `config:"skip_dangling_shortcuts"`
 	ResourceKey               string               `config:"resource_key"`
 	FastListBugFix            bool                 `config:"fast_list_bug_fix"`
+	MetadataOwner             rwChoice             `config:"metadata_owner"`
+	MetadataPermissions       rwChoice             `config:"metadata_permissions"`
+	MetadataLabels            rwChoice             `config:"metadata_labels"`
 	Enc                       encoder.MultiEncoder `config:"encoding"`
 	EnvAuth                   bool                 `config:"env_auth"`
 }
@@ -736,23 +828,25 @@ type Fs struct {
 	exportExtensions []string           // preferred extensions to download docs
 	importMimeTypes  []string           // MIME types to convert to docs
 	isTeamDrive      bool               // true if this is a team drive
-	fileFields       googleapi.Field    // fields to fetch file info with
 	m                configmap.Mapper
-	grouping         int32               // number of IDs to search at once in ListR - read with atomic
-	listRmu          *sync.Mutex         // protects listRempties
-	listRempties     map[string]struct{} // IDs of supposedly empty directories which triggered grouping disable
-	dirResourceKeys  *sync.Map           // map directory ID to resource key
+	grouping         int32                        // number of IDs to search at once in ListR - read with atomic
+	listRmu          *sync.Mutex                  // protects listRempties
+	listRempties     map[string]struct{}          // IDs of supposedly empty directories which triggered grouping disable
+	dirResourceKeys  *sync.Map                    // map directory ID to resource key
+	permissionsMu    *sync.Mutex                  // protect the below
+	permissions      map[string]*drive.Permission // map permission IDs to Permissions
 }
 
 type baseObject struct {
-	fs           *Fs      // what this object is part of
-	remote       string   // The remote path
-	id           string   // Drive Id of this object
-	modifiedDate string   // RFC3339 time it was last modified
-	mimeType     string   // The object MIME type
-	bytes        int64    // size of the object
-	parents      []string // IDs of the parent directories
-	resourceKey  *string  // resourceKey is needed for link shared objects
+	fs           *Fs          // what this object is part of
+	remote       string       // The remote path
+	id           string       // Drive Id of this object
+	modifiedDate string       // RFC3339 time it was last modified
+	mimeType     string       // The object MIME type
+	bytes        int64        // size of the object
+	parents      []string     // IDs of the parent directories
+	resourceKey  *string      // resourceKey is needed for link shared objects
+	metadata     *fs.Metadata // metadata if known
 }
 type documentObject struct {
 	baseObject
@@ -1001,7 +1095,7 @@ func (f *Fs) list(ctx context.Context, dirIDs []string, title string, directorie
 		list.Header().Add("X-Goog-Drive-Resource-Keys", resourceKeysHeader)
 	}
 
-	fields := fmt.Sprintf("files(%s),nextPageToken,incompleteSearch", f.fileFields)
+	fields := fmt.Sprintf("files(%s),nextPageToken,incompleteSearch", f.getFileFields(ctx))
 
 OUTER:
 	for {
@@ -1275,9 +1369,10 @@ func newFs(ctx context.Context, name, path string, m configmap.Mapper) (*Fs, err
 		listRmu:         new(sync.Mutex),
 		listRempties:    make(map[string]struct{}),
 		dirResourceKeys: new(sync.Map),
+		permissionsMu:   new(sync.Mutex),
+		permissions:     make(map[string]*drive.Permission),
 	}
 	f.isTeamDrive = opt.TeamDriveID != ""
-	f.fileFields = f.getFileFields()
 	f.features = (&fs.Features{
 		DuplicateFiles:          true,
 		ReadMimeType:            true,
@@ -1285,6 +1380,9 @@ func newFs(ctx context.Context, name, path string, m configmap.Mapper) (*Fs, err
 		CanHaveEmptyDirectories: true,
 		ServerSideAcrossConfigs: opt.ServerSideAcrossConfigs,
 		FilterAware:             true,
+		ReadMetadata:            true,
+		WriteMetadata:           true,
+		UserMetadata:            true,
 	}).Fill(ctx, f)
 
 	// Create a new authorized Drive client.
@@ -1389,7 +1487,7 @@ func NewFs(ctx context.Context, name, path string, m configmap.Mapper) (fs.Fs, e
 	return f, nil
 }
 
-func (f *Fs) newBaseObject(remote string, info *drive.File) baseObject {
+func (f *Fs) newBaseObject(ctx context.Context, remote string, info *drive.File) (o baseObject, err error) {
 	modifiedDate := info.ModifiedTime
 	if f.opt.UseCreatedDate {
 		modifiedDate = info.CreatedTime
@@ -1400,7 +1498,7 @@ func (f *Fs) newBaseObject(remote string, info *drive.File) baseObject {
 	if f.opt.SizeAsQuota {
 		size = info.QuotaBytesUsed
 	}
-	return baseObject{
+	o = baseObject{
 		fs:           f,
 		remote:       remote,
 		id:           info.Id,
@@ -1409,10 +1507,15 @@ func (f *Fs) newBaseObject(remote string, info *drive.File) baseObject {
 		bytes:        size,
 		parents:      info.Parents,
 	}
+	err = nil
+	if fs.GetConfig(ctx).Metadata {
+		err = o.parseMetadata(ctx, info)
+	}
+	return o, err
 }
 
 // getFileFields gets the fields for a normal file Get or List
-func (f *Fs) getFileFields() (fields googleapi.Field) {
+func (f *Fs) getFileFields(ctx context.Context) (fields googleapi.Field) {
 	fields = partialFields
 	if f.opt.AuthOwnerOnly {
 		fields += ",owners"
@@ -1426,11 +1529,14 @@ func (f *Fs) getFileFields() (fields googleapi.Field) {
 	if f.opt.SizeAsQuota {
 		fields += ",quotaBytesUsed"
 	}
+	if fs.GetConfig(ctx).Metadata {
+		fields += "," + metadataFields
+	}
 	return fields
 }
 
 // newRegularObject creates an fs.Object for a normal drive.File
-func (f *Fs) newRegularObject(remote string, info *drive.File) fs.Object {
+func (f *Fs) newRegularObject(ctx context.Context, remote string, info *drive.File) (obj fs.Object, err error) {
 	// wipe checksum if SkipChecksumGphotos and file is type Photo or Video
 	if f.opt.SkipChecksumGphotos {
 		for _, space := range info.Spaces {
@@ -1443,27 +1549,33 @@ func (f *Fs) newRegularObject(remote string, info *drive.File) fs.Object {
 		}
 	}
 	o := &Object{
-		baseObject: f.newBaseObject(remote, info),
 		url:        fmt.Sprintf("%sfiles/%s?alt=media", f.svc.BasePath, actualID(info.Id)),
 		md5sum:     strings.ToLower(info.Md5Checksum),
 		sha1sum:    strings.ToLower(info.Sha1Checksum),
 		sha256sum:  strings.ToLower(info.Sha256Checksum),
 		v2Download: f.opt.V2DownloadMinSize != -1 && info.Size >= int64(f.opt.V2DownloadMinSize),
 	}
+	o.baseObject, err = f.newBaseObject(ctx, remote, info)
+	if err != nil {
+		return nil, err
+	}
 	if info.ResourceKey != "" {
 		o.resourceKey = &info.ResourceKey
 	}
-	return o
+	return o, nil
 }
 
 // newDocumentObject creates an fs.Object for a google docs drive.File
-func (f *Fs) newDocumentObject(remote string, info *drive.File, extension, exportMimeType string) (fs.Object, error) {
+func (f *Fs) newDocumentObject(ctx context.Context, remote string, info *drive.File, extension, exportMimeType string) (fs.Object, error) {
 	mediaType, _, err := mime.ParseMediaType(exportMimeType)
 	if err != nil {
 		return nil, err
 	}
 	url := info.ExportLinks[mediaType]
-	baseObject := f.newBaseObject(remote+extension, info)
+	baseObject, err := f.newBaseObject(ctx, remote+extension, info)
+	if err != nil {
+		return nil, err
+	}
 	baseObject.bytes = -1
 	baseObject.mimeType = exportMimeType
 	return &documentObject{
@@ -1475,7 +1587,7 @@ func (f *Fs) newDocumentObject(remote string, info *drive.File, extension, expor
 }
 
 // newLinkObject creates an fs.Object that represents a link a google docs drive.File
-func (f *Fs) newLinkObject(remote string, info *drive.File, extension, exportMimeType string) (fs.Object, error) {
+func (f *Fs) newLinkObject(ctx context.Context, remote string, info *drive.File, extension, exportMimeType string) (fs.Object, error) {
 	t := linkTemplate(exportMimeType)
 	if t == nil {
 		return nil, fmt.Errorf("unsupported link type %s", exportMimeType)
@@ -1494,7 +1606,10 @@ func (f *Fs) newLinkObject(remote string, info *drive.File, extension, exportMim
 		return nil, fmt.Errorf("executing template failed: %w", err)
 	}
 
-	baseObject := f.newBaseObject(remote+extension, info)
+	baseObject, err := f.newBaseObject(ctx, remote+extension, info)
+	if err != nil {
+		return nil, err
+	}
 	baseObject.bytes = int64(buf.Len())
 	baseObject.mimeType = exportMimeType
 	return &linkObject{
@@ -1510,7 +1625,7 @@ func (f *Fs) newLinkObject(remote string, info *drive.File, extension, exportMim
 func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *drive.File) (fs.Object, error) {
 	// If item has MD5 sum it is a file stored on drive
 	if info.Md5Checksum != "" {
-		return f.newRegularObject(remote, info), nil
+		return f.newRegularObject(ctx, remote, info)
 	}
 
 	extension, exportName, exportMimeType, isDocument := f.findExportFormat(ctx, info)
@@ -1541,15 +1656,15 @@ func (f *Fs) newObjectWithExportInfo(
 	case info.MimeType == shortcutMimeTypeDangling:
 		// Pretend a dangling shortcut is a regular object
 		// It will error if used, but appear in listings so it can be deleted
-		return f.newRegularObject(remote, info), nil
+		return f.newRegularObject(ctx, remote, info)
 	case info.Md5Checksum != "":
 		// If item has MD5 sum it is a file stored on drive
-		return f.newRegularObject(remote, info), nil
+		return f.newRegularObject(ctx, remote, info)
 	case f.opt.SkipGdocs:
 		fs.Debugf(remote, "Skipping google document type %q", info.MimeType)
 		return nil, fs.ErrorObjectNotFound
 	case f.opt.ShowAllGdocs:
-		return f.newDocumentObject(remote, info, "", info.MimeType)
+		return f.newDocumentObject(ctx, remote, info, "", info.MimeType)
 	default:
 		// If item MimeType is in the ExportFormats then it is a google doc
 		if !isDocument {
@@ -1561,9 +1676,9 @@ func (f *Fs) newObjectWithExportInfo(
 			return nil, fs.ErrorObjectNotFound
 		}
 		if isLinkMimeType(exportMimeType) {
-			return f.newLinkObject(remote, info, extension, exportMimeType)
+			return f.newLinkObject(ctx, remote, info, extension, exportMimeType)
 		}
-		return f.newDocumentObject(remote, info, extension, exportMimeType)
+		return f.newDocumentObject(ctx, remote, info, extension, exportMimeType)
 	}
 }
 
@@ -2191,7 +2306,7 @@ func (f *Fs) resolveShortcut(ctx context.Context, item *drive.File) (newItem *dr
 		fs.Errorf(nil, "Expecting shortcutDetails in %v", item)
 		return item, nil
 	}
-	newItem, err = f.getFile(ctx, item.ShortcutDetails.TargetId, f.fileFields)
+	newItem, err = f.getFile(ctx, item.ShortcutDetails.TargetId, f.getFileFields(ctx))
 	if err != nil {
 		var gerr *googleapi.Error
 		if errors.As(err, &gerr) && gerr.Code == 404 {
@@ -2323,6 +2438,10 @@ func (f *Fs) PutUnchecked(ctx context.Context, in io.Reader, src fs.ObjectInfo,
 	} else {
 		createInfo.MimeType = fs.MimeTypeFromName(remote)
 	}
+	updateMetadata, err := f.fetchAndUpdateMetadata(ctx, src, options, createInfo, false)
+	if err != nil {
+		return nil, err
+	}
 
 	var info *drive.File
 	if size >= 0 && size < int64(f.opt.UploadCutoff) {
@@ -2347,6 +2466,10 @@ func (f *Fs) PutUnchecked(ctx context.Context, in io.Reader, src fs.ObjectInfo,
 			return nil, err
 		}
 	}
+	err = updateMetadata(ctx, info)
+	if err != nil {
+		return nil, err
+	}
 	return f.newObjectWithInfo(ctx, remote, info)
 }
 
@@ -3264,7 +3387,7 @@ func (f *Fs) unTrashDir(ctx context.Context, dir string, recurse bool) (r unTras
 
 // copy file with id to dest
 func (f *Fs) copyID(ctx context.Context, id, dest string) (err error) {
-	info, err := f.getFile(ctx, id, f.fileFields)
+	info, err := f.getFile(ctx, id, f.getFileFields(ctx))
 	if err != nil {
 		return fmt.Errorf("couldn't find id: %w", err)
 	}
@@ -3944,10 +4067,20 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		MimeType:     srcMimeType,
 		ModifiedTime: src.ModTime(ctx).Format(timeFormatOut),
 	}
+
+	updateMetadata, err := o.fs.fetchAndUpdateMetadata(ctx, src, options, updateInfo, true)
+	if err != nil {
+		return err
+	}
+
 	info, err := o.baseObject.update(ctx, updateInfo, srcMimeType, in, src)
 	if err != nil {
 		return err
 	}
+	err = updateMetadata(ctx, info)
+	if err != nil {
+		return err
+	}
 	newO, err := o.fs.newObjectWithInfo(ctx, o.remote, info)
 	if err != nil {
 		return err
@@ -4033,6 +4166,26 @@ func (o *baseObject) ParentID() string {
 	return ""
 }
 
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *baseObject) Metadata(ctx context.Context) (metadata fs.Metadata, err error) {
+	if o.metadata != nil {
+		return *o.metadata, nil
+	}
+	fs.Debugf(o, "Fetching metadata")
+	id := actualID(o.id)
+	info, err := o.fs.getFile(ctx, id, o.fs.getFileFields(ctx))
+	if err != nil {
+		return nil, err
+	}
+	err = o.parseMetadata(ctx, info)
+	if err != nil {
+		return nil, err
+	}
+	return *o.metadata, nil
+}
+
 func (o *documentObject) ext() string {
 	return o.baseObject.remote[len(o.baseObject.remote)-o.extLen:]
 }
@@ -4094,6 +4247,7 @@ var (
 	_ fs.MimeTyper       = (*Object)(nil)
 	_ fs.IDer            = (*Object)(nil)
 	_ fs.ParentIDer      = (*Object)(nil)
+	_ fs.Metadataer      = (*Object)(nil)
 	_ fs.Object          = (*documentObject)(nil)
 	_ fs.MimeTyper       = (*documentObject)(nil)
 	_ fs.IDer            = (*documentObject)(nil)
diff --git a/backend/drive/metadata.go b/backend/drive/metadata.go
new file mode 100644
index 000000000..ab2ae94d0
--- /dev/null
+++ b/backend/drive/metadata.go
@@ -0,0 +1,608 @@
+package drive
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+	"sync"
+
+	"github.com/rclone/rclone/fs"
+	"golang.org/x/sync/errgroup"
+	drive "google.golang.org/api/drive/v3"
+	"google.golang.org/api/googleapi"
+)
+
+// system metadata keys which this backend owns
+var systemMetadataInfo = map[string]fs.MetadataHelp{
+	"content-type": {
+		Help:    "The MIME type of the file.",
+		Type:    "string",
+		Example: "text/plain",
+	},
+	"mtime": {
+		Help:    "Time of last modification with mS accuracy.",
+		Type:    "RFC 3339",
+		Example: "2006-01-02T15:04:05.999Z07:00",
+	},
+	"btime": {
+		Help:    "Time of file birth (creation) with mS accuracy. Note that this is only writable on fresh uploads - it can't be written for updates.",
+		Type:    "RFC 3339",
+		Example: "2006-01-02T15:04:05.999Z07:00",
+	},
+	"copy-requires-writer-permission": {
+		Help:    "Whether the options to copy, print, or download this file, should be disabled for readers and commenters.",
+		Type:    "boolean",
+		Example: "true",
+	},
+	"writers-can-share": {
+		Help:    "Whether users with only writer permission can modify the file's permissions. Not populated for items in shared drives.",
+		Type:    "boolean",
+		Example: "false",
+	},
+	"viewed-by-me": {
+		Help:     "Whether the file has been viewed by this user.",
+		Type:     "boolean",
+		Example:  "true",
+		ReadOnly: true,
+	},
+	"owner": {
+		Help:    "The owner of the file. Usually an email address. Enable with --drive-metadata-owner.",
+		Type:    "string",
+		Example: "user@example.com",
+	},
+	"permissions": {
+		Help:    "Permissions in a JSON dump of Google drive format. On shared drives these will only be present if they aren't inherited. Enable with --drive-metadata-permissions.",
+		Type:    "JSON",
+		Example: "{}",
+	},
+	"folder-color-rgb": {
+		Help:    "The color for a folder or a shortcut to a folder as an RGB hex string.",
+		Type:    "string",
+		Example: "881133",
+	},
+	"description": {
+		Help:    "A short description of the file.",
+		Type:    "string",
+		Example: "Contract for signing",
+	},
+	"starred": {
+		Help:    "Whether the user has starred the file.",
+		Type:    "boolean",
+		Example: "false",
+	},
+	"labels": {
+		Help:    "Labels attached to this file in a JSON dump of Googled drive format. Enable with --drive-metadata-labels.",
+		Type:    "JSON",
+		Example: "[]",
+	},
+}
+
+// Extra fields we need to fetch to implement the system metadata above
+var metadataFields = googleapi.Field(strings.Join([]string{
+	"copyRequiresWriterPermission",
+	"description",
+	"folderColorRgb",
+	"hasAugmentedPermissions",
+	"owners",
+	"permissionIds",
+	"permissions",
+	"properties",
+	"starred",
+	"viewedByMe",
+	"viewedByMeTime",
+	"writersCanShare",
+}, ","))
+
+// Fields we need to read from permissions
+var permissionsFields = googleapi.Field(strings.Join([]string{
+	"*",
+	"permissionDetails/*",
+}, ","))
+
+// getPermission returns permissions for the fileID and permissionID passed in
+func (f *Fs) getPermission(ctx context.Context, fileID, permissionID string, useCache bool) (perm *drive.Permission, inherited bool, err error) {
+	f.permissionsMu.Lock()
+	defer f.permissionsMu.Unlock()
+	if useCache {
+		perm = f.permissions[permissionID]
+		if perm != nil {
+			return perm, false, nil
+		}
+	}
+	fs.Debugf(f, "Fetching permission %q", permissionID)
+	err = f.pacer.Call(func() (bool, error) {
+		perm, err = f.svc.Permissions.Get(fileID, permissionID).
+			Fields(permissionsFields).
+			SupportsAllDrives(true).
+			Context(ctx).Do()
+		return f.shouldRetry(ctx, err)
+	})
+	if err != nil {
+		return nil, false, err
+	}
+
+	inherited = len(perm.PermissionDetails) > 0 && perm.PermissionDetails[0].Inherited
+
+	cleanPermission(perm)
+
+	// cache the permission
+	f.permissions[permissionID] = perm
+
+	return perm, inherited, err
+}
+
+// Set the permissions on the info
+func (f *Fs) setPermissions(ctx context.Context, info *drive.File, permissions []*drive.Permission) (err error) {
+	for _, perm := range permissions {
+		if perm.Role == "owner" {
+			// ignore owner permissions - these are set with owner
+			continue
+		}
+		cleanPermissionForWrite(perm)
+		err = f.pacer.Call(func() (bool, error) {
+			_, err = f.svc.Permissions.Create(info.Id, perm).
+				SupportsAllDrives(true).
+				Context(ctx).Do()
+			return f.shouldRetry(ctx, err)
+		})
+		if err != nil {
+			return fmt.Errorf("failed to set permission: %w", err)
+		}
+	}
+	return nil
+}
+
+// Clean attributes from permissions which we can't write
+func cleanPermissionForWrite(perm *drive.Permission) {
+	perm.Deleted = false
+	perm.DisplayName = ""
+	perm.Id = ""
+	perm.Kind = ""
+	perm.PermissionDetails = nil
+	perm.TeamDrivePermissionDetails = nil
+}
+
+// Clean and cache the permission if not already cached
+func (f *Fs) cleanAndCachePermission(perm *drive.Permission) {
+	f.permissionsMu.Lock()
+	defer f.permissionsMu.Unlock()
+	cleanPermission(perm)
+	if _, found := f.permissions[perm.Id]; !found {
+		f.permissions[perm.Id] = perm
+	}
+}
+
+// Clean fields we don't need to keep from the permission
+func cleanPermission(perm *drive.Permission) {
+	// DisplayName: Output only. The "pretty" name of the value of the
+	// permission. The following is a list of examples for each type of
+	// permission: * `user` - User's full name, as defined for their Google
+	// account, such as "Joe Smith." * `group` - Name of the Google Group,
+	// such as "The Company Administrators." * `domain` - String domain
+	// name, such as "thecompany.com." * `anyone` - No `displayName` is
+	// present.
+	perm.DisplayName = ""
+
+	// Kind: Output only. Identifies what kind of resource this is. Value:
+	// the fixed string "drive#permission".
+	perm.Kind = ""
+
+	// PermissionDetails: Output only. Details of whether the permissions on
+	// this shared drive item are inherited or directly on this item. This
+	// is an output-only field which is present only for shared drive items.
+	perm.PermissionDetails = nil
+
+	// PhotoLink: Output only. A link to the user's profile photo, if
+	// available.
+	perm.PhotoLink = ""
+
+	// TeamDrivePermissionDetails: Output only. Deprecated: Output only. Use
+	// `permissionDetails` instead.
+	perm.TeamDrivePermissionDetails = nil
+}
+
+// Fields we need to read from labels
+var labelsFields = googleapi.Field(strings.Join([]string{
+	"*",
+}, ","))
+
+// getLabels returns labels for the fileID passed in
+func (f *Fs) getLabels(ctx context.Context, fileID string) (labels []*drive.Label, err error) {
+	fs.Debugf(f, "Fetching labels for %q", fileID)
+	listLabels := f.svc.Files.ListLabels(fileID).
+		Fields(labelsFields).
+		Context(ctx)
+	for {
+		var info *drive.LabelList
+		err = f.pacer.Call(func() (bool, error) {
+			info, err = listLabels.Do()
+			return f.shouldRetry(ctx, err)
+		})
+		if err != nil {
+			return nil, err
+		}
+		labels = append(labels, info.Labels...)
+		if info.NextPageToken == "" {
+			break
+		}
+		listLabels.PageToken(info.NextPageToken)
+	}
+	for _, label := range labels {
+		cleanLabel(label)
+	}
+	return labels, nil
+}
+
+// Set the labels on the info
+func (f *Fs) setLabels(ctx context.Context, info *drive.File, labels []*drive.Label) (err error) {
+	if len(labels) == 0 {
+		return nil
+	}
+	req := drive.ModifyLabelsRequest{}
+	for _, label := range labels {
+		req.LabelModifications = append(req.LabelModifications, &drive.LabelModification{
+			FieldModifications: labelFieldsToFieldModifications(label.Fields),
+			LabelId:            label.Id,
+		})
+	}
+	err = f.pacer.Call(func() (bool, error) {
+		_, err = f.svc.Files.ModifyLabels(info.Id, &req).
+			Context(ctx).Do()
+		return f.shouldRetry(ctx, err)
+	})
+	if err != nil {
+		return fmt.Errorf("failed to set owner: %w", err)
+	}
+	return nil
+}
+
+// Convert label fields into something which can set the fields
+func labelFieldsToFieldModifications(fields map[string]drive.LabelField) (out []*drive.LabelFieldModification) {
+	for id, field := range fields {
+		var emails []string
+		for _, user := range field.User {
+			emails = append(emails, user.EmailAddress)
+		}
+		out = append(out, &drive.LabelFieldModification{
+			// FieldId: The ID of the field to be modified.
+			FieldId: id,
+
+			// SetDateValues: Replaces the value of a dateString Field with these
+			// new values. The string must be in the RFC 3339 full-date format:
+			// YYYY-MM-DD.
+			SetDateValues: field.DateString,
+
+			// SetIntegerValues: Replaces the value of an `integer` field with these
+			// new values.
+			SetIntegerValues: field.Integer,
+
+			// SetSelectionValues: Replaces a `selection` field with these new
+			// values.
+			SetSelectionValues: field.Selection,
+
+			// SetTextValues: Sets the value of a `text` field.
+			SetTextValues: field.Text,
+
+			// SetUserValues: Replaces a `user` field with these new values. The
+			// values must be valid email addresses.
+			SetUserValues: emails,
+		})
+	}
+	return out
+}
+
+// Clean fields we don't need to keep from the label
+func cleanLabel(label *drive.Label) {
+	// Kind: This is always drive#label
+	label.Kind = ""
+
+	for name, field := range label.Fields {
+		// Kind: This is always drive#labelField.
+		field.Kind = ""
+
+		// Note the fields are copies so we need to write them
+		// back to the map
+		label.Fields[name] = field
+	}
+}
+
+// Parse the metadata from drive item
+//
+// It should return nil if there is no Metadata
+func (o *baseObject) parseMetadata(ctx context.Context, info *drive.File) (err error) {
+	metadata := make(fs.Metadata, 16)
+
+	// Dump user metadata first as it overrides system metadata
+	for k, v := range info.Properties {
+		metadata[k] = v
+	}
+
+	// System metadata
+	metadata["copy-requires-writer-permission"] = fmt.Sprint(info.CopyRequiresWriterPermission)
+	metadata["writers-can-share"] = fmt.Sprint(info.WritersCanShare)
+	metadata["viewed-by-me"] = fmt.Sprint(info.ViewedByMe)
+	metadata["content-type"] = info.MimeType
+
+	// Owners: Output only. The owner of this file. Only certain legacy
+	// files may have more than one owner. This field isn't populated for
+	// items in shared drives.
+	if o.fs.opt.MetadataOwner.IsSet(rwRead) && len(info.Owners) > 0 {
+		user := info.Owners[0]
+		if len(info.Owners) > 1 {
+			fs.Logf(o, "Ignoring more than 1 owner")
+		}
+		if user != nil {
+			id := user.EmailAddress
+			if id == "" {
+				id = user.DisplayName
+			}
+			metadata["owner"] = id
+		}
+	}
+
+	if o.fs.opt.MetadataPermissions.IsSet(rwRead) {
+		// We only write permissions out if they are not inherited.
+		//
+		// On My Drives permissions seem to be attached to every item
+		// so they will always be written out.
+		//
+		// On Shared Drives only non-inherited permissions will be
+		// written out.
+
+		// To read the inherited permissions flag will mean we need to
+		// read the permissions for each object and the cache will be
+		// useless. However shared drives don't return permissions
+		// only permissionIds so will need to fetch them for each
+		// object. We use HasAugmentedPermissions to see if there are
+		// special permissions before fetching them to save transactions.
+
+		// HasAugmentedPermissions: Output only. Whether there are permissions
+		// directly on this file. This field is only populated for items in
+		// shared drives.
+		if o.fs.isTeamDrive && !info.HasAugmentedPermissions {
+			// Don't process permissions if there aren't any specifically set
+			info.Permissions = nil
+			info.PermissionIds = nil
+		}
+
+		// PermissionIds: Output only. List of permission IDs for users with
+		// access to this file.
+		//
+		// Only process these if we have no Permissions
+		if len(info.PermissionIds) > 0 && len(info.Permissions) == 0 {
+			info.Permissions = make([]*drive.Permission, 0, len(info.PermissionIds))
+			g, gCtx := errgroup.WithContext(ctx)
+			g.SetLimit(o.fs.ci.Checkers)
+			var mu sync.Mutex // protect the info.Permissions from concurrent writes
+			for _, permissionID := range info.PermissionIds {
+				permissionID := permissionID
+				g.Go(func() error {
+					// must fetch the team drive ones individually to check the inherited flag
+					perm, inherited, err := o.fs.getPermission(gCtx, actualID(info.Id), permissionID, !o.fs.isTeamDrive)
+					if err != nil {
+						return fmt.Errorf("failed to read permission: %w", err)
+					}
+					// Don't write inherited permissions out
+					if inherited {
+						return nil
+					}
+					// Don't write owner role out - these are covered by the owner metadata
+					if perm.Role == "owner" {
+						return nil
+					}
+					mu.Lock()
+					info.Permissions = append(info.Permissions, perm)
+					mu.Unlock()
+					return nil
+				})
+			}
+			err = g.Wait()
+			if err != nil {
+				return err
+			}
+		} else {
+			// Clean the fetched permissions
+			for _, perm := range info.Permissions {
+				o.fs.cleanAndCachePermission(perm)
+			}
+		}
+
+		// Permissions: Output only. The full list of permissions for the file.
+		// This is only available if the requesting user can share the file. Not
+		// populated for items in shared drives.
+		if len(info.Permissions) > 0 {
+			buf, err := json.Marshal(info.Permissions)
+			if err != nil {
+				return fmt.Errorf("failed to marshal permissions: %w", err)
+			}
+			metadata["permissions"] = string(buf)
+		}
+
+		// Permission propagation
+		// https://developers.google.com/drive/api/guides/manage-sharing#permission-propagation
+		// Leads me to believe that in non shared drives, permissions
+		// are added to each item when you set permissions for a
+		// folder whereas in shared drives they are inherited and
+		// placed on the item directly.
+	}
+
+	if info.FolderColorRgb != "" {
+		metadata["folder-color-rgb"] = info.FolderColorRgb
+	}
+	if info.Description != "" {
+		metadata["description"] = info.Description
+	}
+	metadata["starred"] = fmt.Sprint(info.Starred)
+	metadata["btime"] = info.CreatedTime
+	metadata["mtime"] = info.ModifiedTime
+
+	if o.fs.opt.MetadataLabels.IsSet(rwRead) {
+		// FIXME would be really nice if we knew if files had labels
+		// before listing but we need to know all possible label IDs
+		// to get it in the listing.
+
+		labels, err := o.fs.getLabels(ctx, actualID(info.Id))
+		if err != nil {
+			return fmt.Errorf("failed to fetch labels: %w", err)
+		}
+		buf, err := json.Marshal(labels)
+		if err != nil {
+			return fmt.Errorf("failed to marshal labels: %w", err)
+		}
+		metadata["labels"] = string(buf)
+	}
+
+	o.metadata = &metadata
+	return nil
+}
+
+// Set the owner on the info
+func (f *Fs) setOwner(ctx context.Context, info *drive.File, owner string) (err error) {
+	perm := drive.Permission{
+		Role:         "owner",
+		EmailAddress: owner,
+		// Type: The type of the grantee. Valid values are: * `user` * `group` *
+		// `domain` * `anyone` When creating a permission, if `type` is `user`
+		// or `group`, you must provide an `emailAddress` for the user or group.
+		// When `type` is `domain`, you must provide a `domain`. There isn't
+		// extra information required for an `anyone` type.
+		Type: "user",
+	}
+	err = f.pacer.Call(func() (bool, error) {
+		_, err = f.svc.Permissions.Create(info.Id, &perm).
+			SupportsAllDrives(true).
+			TransferOwnership(true).
+			// SendNotificationEmail(false). - required apparently!
+			Context(ctx).Do()
+		return f.shouldRetry(ctx, err)
+	})
+	if err != nil {
+		return fmt.Errorf("failed to set owner: %w", err)
+	}
+	return nil
+}
+
+// Call back to set metadata that can't be set on the upload/update
+//
+// The *drive.File passed in holds the current state of the drive.File
+// and this should update it with any modifications.
+type updateMetadataFn func(context.Context, *drive.File) error
+
+// read the metadata from meta and write it into updateInfo
+//
+// update should be true if this is being used to create metadata for
+// an update/PATCH call as the rules on what can be updated are
+// slightly different there.
+//
+// It returns a callback which should be called to finish the updates
+// after the data is uploaded.
+func (f *Fs) updateMetadata(ctx context.Context, updateInfo *drive.File, meta fs.Metadata, update bool) (callback updateMetadataFn, err error) {
+	callbackFns := []updateMetadataFn{}
+	callback = func(ctx context.Context, info *drive.File) error {
+		for _, fn := range callbackFns {
+			err := fn(ctx, info)
+			if err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+	// merge metadata into request and user metadata
+	for k, v := range meta {
+		k, v := k, v
+		// parse a boolean from v and write into out
+		parseBool := func(out *bool) error {
+			b, err := strconv.ParseBool(v)
+			if err != nil {
+				return fmt.Errorf("can't parse metadata %q = %q: %w", k, v, err)
+			}
+			*out = b
+			return nil
+		}
+		switch k {
+		case "copy-requires-writer-permission":
+			if err := parseBool(&updateInfo.CopyRequiresWriterPermission); err != nil {
+				return nil, err
+			}
+		case "writers-can-share":
+			if err := parseBool(&updateInfo.WritersCanShare); err != nil {
+				return nil, err
+			}
+		case "viewed-by-me":
+			// Can't write this
+		case "content-type":
+			updateInfo.MimeType = v
+		case "owner":
+			if !f.opt.MetadataOwner.IsSet(rwWrite) {
+				continue
+			}
+			// Can't set Owner on upload so need to set afterwards
+			callbackFns = append(callbackFns, func(ctx context.Context, info *drive.File) error {
+				return f.setOwner(ctx, info, v)
+			})
+		case "permissions":
+			if !f.opt.MetadataPermissions.IsSet(rwWrite) {
+				continue
+			}
+			var perms []*drive.Permission
+			err := json.Unmarshal([]byte(v), &perms)
+			if err != nil {
+				return nil, fmt.Errorf("failed to unmarshal permissions: %w", err)
+			}
+			// Can't set Permissions on upload so need to set afterwards
+			callbackFns = append(callbackFns, func(ctx context.Context, info *drive.File) error {
+				return f.setPermissions(ctx, info, perms)
+			})
+		case "labels":
+			if !f.opt.MetadataLabels.IsSet(rwWrite) {
+				continue
+			}
+			var labels []*drive.Label
+			err := json.Unmarshal([]byte(v), &labels)
+			if err != nil {
+				return nil, fmt.Errorf("failed to unmarshal labels: %w", err)
+			}
+			// Can't set Labels on upload so need to set afterwards
+			callbackFns = append(callbackFns, func(ctx context.Context, info *drive.File) error {
+				return f.setLabels(ctx, info, labels)
+			})
+		case "folder-color-rgb":
+			updateInfo.FolderColorRgb = v
+		case "description":
+			updateInfo.Description = v
+		case "starred":
+			if err := parseBool(&updateInfo.Starred); err != nil {
+				return nil, err
+			}
+		case "btime":
+			if update {
+				fs.Debugf(f, "Skipping btime metadata as can't update it on an existing file: %v", v)
+			} else {
+				updateInfo.CreatedTime = v
+			}
+		case "mtime":
+			updateInfo.ModifiedTime = v
+		default:
+			if updateInfo.Properties == nil {
+				updateInfo.Properties = make(map[string]string, 1)
+			}
+			updateInfo.Properties[k] = v
+		}
+	}
+	return callback, nil
+}
+
+// Fetch metadata and update updateInfo if --metadata is in use
+func (f *Fs) fetchAndUpdateMetadata(ctx context.Context, src fs.ObjectInfo, options []fs.OpenOption, updateInfo *drive.File, update bool) (callback updateMetadataFn, err error) {
+	meta, err := fs.GetMetadataOptions(ctx, f, src, options)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read metadata from source object: %w", err)
+	}
+	callback, err = f.updateMetadata(ctx, updateInfo, meta, update)
+	if err != nil {
+		return nil, fmt.Errorf("failed to update metadata from source object: %w", err)
+	}
+	return callback, nil
+}
diff --git a/backend/internetarchive/internetarchive.go b/backend/internetarchive/internetarchive.go
index ad03ead4c..8718401ec 100644
--- a/backend/internetarchive/internetarchive.go
+++ b/backend/internetarchive/internetarchive.go
@@ -802,7 +802,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		headers["x-archive-size-hint"] = fmt.Sprintf("%d", size)
 	}
 	var mdata fs.Metadata
-	mdata, err = fs.GetMetadataOptions(ctx, src, options)
+	mdata, err = fs.GetMetadataOptions(ctx, o.fs, src, options)
 	if err == nil && mdata != nil {
 		for mk, mv := range mdata {
 			mk = strings.ToLower(mk)
diff --git a/backend/jottacloud/jottacloud.go b/backend/jottacloud/jottacloud.go
index 7c48c116d..e9176f5b0 100644
--- a/backend/jottacloud/jottacloud.go
+++ b/backend/jottacloud/jottacloud.go
@@ -1944,7 +1944,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		in = wrap(in)
 	}
 	// Fetch metadata if --metadata is in use
-	meta, err := fs.GetMetadataOptions(ctx, src, options)
+	meta, err := fs.GetMetadataOptions(ctx, o.fs, src, options)
 	if err != nil {
 		return fmt.Errorf("failed to read metadata from source object: %w", err)
 	}
diff --git a/backend/linkbox/linkbox.go b/backend/linkbox/linkbox.go
new file mode 100644
index 000000000..75445ea72
--- /dev/null
+++ b/backend/linkbox/linkbox.go
@@ -0,0 +1,897 @@
+// Package linkbox provides an interface to the linkbox.to Cloud storage system.
+//
+// API docs: https://www.linkbox.to/api-docs
+package linkbox
+
+/*
+   Extras
+   - PublicLink - NO - sharing doesn't share the actual file, only a page with it on
+   - Move - YES - have Move and Rename file APIs so is possible
+   - MoveDir - NO - probably not possible - have Move but no Rename
+*/
+
+import (
+	"bytes"
+	"context"
+	"crypto/md5"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"path"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fs/config/configmap"
+	"github.com/rclone/rclone/fs/config/configstruct"
+	"github.com/rclone/rclone/fs/fserrors"
+	"github.com/rclone/rclone/fs/fshttp"
+	"github.com/rclone/rclone/fs/hash"
+	"github.com/rclone/rclone/lib/dircache"
+	"github.com/rclone/rclone/lib/pacer"
+	"github.com/rclone/rclone/lib/rest"
+)
+
+const (
+	maxEntitiesPerPage = 1024
+	minSleep           = 200 * time.Millisecond
+	maxSleep           = 2 * time.Second
+	pacerBurst         = 1
+	linkboxAPIURL      = "https://www.linkbox.to/api/open/"
+	rootID             = "0" // ID of root directory
+)
+
+func init() {
+	fsi := &fs.RegInfo{
+		Name:        "linkbox",
+		Description: "Linkbox",
+		NewFs:       NewFs,
+		Options: []fs.Option{{
+			Name:      "token",
+			Help:      "Token from https://www.linkbox.to/admin/account",
+			Sensitive: true,
+			Required:  true,
+		}},
+	}
+	fs.Register(fsi)
+}
+
+// Options defines the configuration for this backend
+type Options struct {
+	Token string `config:"token"`
+}
+
+// Fs stores the interface to the remote Linkbox files
+type Fs struct {
+	name     string
+	root     string
+	opt      Options            // options for this backend
+	features *fs.Features       // optional features
+	ci       *fs.ConfigInfo     // global config
+	srv      *rest.Client       // the connection to the server
+	dirCache *dircache.DirCache // Map of directory path to directory id
+	pacer    *fs.Pacer
+}
+
+// Object is a remote object that has been stat'd (so it exists, but is not necessarily open for reading)
+type Object struct {
+	fs          *Fs
+	remote      string
+	size        int64
+	modTime     time.Time
+	contentType string
+	fullURL     string
+	dirID       int64
+	itemID      string // and these IDs are for files
+	id          int64  // these IDs appear to apply to directories
+	isDir       bool
+}
+
+// NewFs creates a new Fs object from the name and root. It connects to
+// the host specified in the config file.
+func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
+	root = strings.Trim(root, "/")
+	// Parse config into Options struct
+
+	opt := new(Options)
+	err := configstruct.Set(m, opt)
+	if err != nil {
+		return nil, err
+	}
+
+	ci := fs.GetConfig(ctx)
+
+	f := &Fs{
+		name: name,
+		opt:  *opt,
+		root: root,
+		ci:   ci,
+		srv:  rest.NewClient(fshttp.NewClient(ctx)),
+
+		pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep))),
+	}
+	f.dirCache = dircache.New(root, rootID, f)
+
+	f.features = (&fs.Features{
+		CanHaveEmptyDirectories: true,
+		CaseInsensitive:         true,
+	}).Fill(ctx, f)
+
+	// Find the current root
+	err = f.dirCache.FindRoot(ctx, false)
+	if err != nil {
+		// Assume it is a file
+		newRoot, remote := dircache.SplitPath(root)
+		tempF := *f
+		tempF.dirCache = dircache.New(newRoot, rootID, &tempF)
+		tempF.root = newRoot
+		// Make new Fs which is the parent
+		err = tempF.dirCache.FindRoot(ctx, false)
+		if err != nil {
+			// No root so return old f
+			return f, nil
+		}
+		_, err := tempF.NewObject(ctx, remote)
+		if err != nil {
+			if err == fs.ErrorObjectNotFound {
+				// File doesn't exist so return old f
+				return f, nil
+			}
+			return nil, err
+		}
+		f.features.Fill(ctx, &tempF)
+		// XXX: update the old f here instead of returning tempF, since
+		// `features` were already filled with functions having *f as a receiver.
+		// See https://github.com/rclone/rclone/issues/2182
+		f.dirCache = tempF.dirCache
+		f.root = tempF.root
+		// return an error with an fs which points to the parent
+		return f, fs.ErrorIsFile
+	}
+	return f, nil
+}
+
+type entity struct {
+	Type   string `json:"type"`
+	Name   string `json:"name"`
+	URL    string `json:"url"`
+	Ctime  int64  `json:"ctime"`
+	Size   int64  `json:"size"`
+	ID     int64  `json:"id"`
+	Pid    int64  `json:"pid"`
+	ItemID string `json:"item_id"`
+}
+
+// Return true if the entity is a directory
+func (e *entity) isDir() bool {
+	return e.Type == "dir" || e.Type == "sdir"
+}
+
+type data struct {
+	Entities []entity `json:"list"`
+}
+type fileSearchRes struct {
+	response
+	SearchData data `json:"data"`
+}
+
+// Set an object info from an entity
+func (o *Object) set(e *entity) {
+	o.modTime = time.Unix(e.Ctime, 0)
+	o.contentType = e.Type
+	o.size = e.Size
+	o.fullURL = e.URL
+	o.isDir = e.isDir()
+	o.id = e.ID
+	o.itemID = e.ItemID
+	o.dirID = e.Pid
+}
+
+// Call linkbox with the query in opts and return result
+//
+// This will be checked for error and an error will be returned if Status != 1
+func getUnmarshaledResponse(ctx context.Context, f *Fs, opts *rest.Opts, result interface{}) error {
+	err := f.pacer.Call(func() (bool, error) {
+		resp, err := f.srv.CallJSON(ctx, opts, nil, &result)
+		return f.shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return err
+	}
+	responser := result.(responser)
+	if responser.IsError() {
+		return responser
+	}
+	return nil
+}
+
+// list the objects into the function supplied
+//
+// If directories is set it only sends directories
+// User function to process a File item from listAll
+//
+// Should return true to finish processing
+type listAllFn func(*entity) bool
+
+// Search is a bit fussy about which characters match
+//
+// If the name doesn't match this then do an dir list instead
+var searchOK = regexp.MustCompile(`^[a-zA-Z0-9_ .]+$`)
+
+// Lists the directory required calling the user function on each item found
+//
+// If the user fn ever returns true then it early exits with found = true
+//
+// If you set name then search ignores dirID. name is a substring
+// search also so name="dir" matches "sub dir" also. This filters it
+// down so it only returns items in dirID
+func (f *Fs) listAll(ctx context.Context, dirID string, name string, fn listAllFn) (found bool, err error) {
+	var (
+		pageNumber       = 0
+		numberOfEntities = maxEntitiesPerPage
+	)
+	name = strings.TrimSpace(name) // search doesn't like spaces
+	if !searchOK.MatchString(name) {
+		// If name isn't good then do an unbounded search
+		name = ""
+	}
+OUTER:
+	for numberOfEntities == maxEntitiesPerPage {
+		pageNumber++
+		opts := &rest.Opts{
+			Method:  "GET",
+			RootURL: linkboxAPIURL,
+			Path:    "file_search",
+			Parameters: url.Values{
+				"token":    {f.opt.Token},
+				"name":     {name},
+				"pid":      {dirID},
+				"pageNo":   {itoa(pageNumber)},
+				"pageSize": {itoa64(maxEntitiesPerPage)},
+			},
+		}
+
+		var responseResult fileSearchRes
+		err = getUnmarshaledResponse(ctx, f, opts, &responseResult)
+		if err != nil {
+			return false, fmt.Errorf("getting files failed: %w", err)
+
+		}
+
+		numberOfEntities = len(responseResult.SearchData.Entities)
+
+		for _, entity := range responseResult.SearchData.Entities {
+			if itoa64(entity.Pid) != dirID {
+				// when name != "" this returns from all directories, so ignore not this one
+				continue
+			}
+			if fn(&entity) {
+				found = true
+				break OUTER
+			}
+		}
+		if pageNumber > 100000 {
+			return false, fmt.Errorf("too many results")
+		}
+	}
+	return found, nil
+}
+
+// Turn 64 bit int to string
+func itoa64(i int64) string {
+	return strconv.FormatInt(i, 10)
+}
+
+// Turn int to string
+func itoa(i int) string {
+	return itoa64(int64(i))
+}
+
+func splitDirAndName(remote string) (dir string, name string) {
+	lastSlashPosition := strings.LastIndex(remote, "/")
+	if lastSlashPosition == -1 {
+		dir = ""
+		name = remote
+	} else {
+		dir = remote[:lastSlashPosition]
+		name = remote[lastSlashPosition+1:]
+	}
+
+	// fs.Debugf(nil, "splitDirAndName remote = {%s}, dir = {%s}, name = {%s}", remote, dir, name)
+
+	return dir, name
+}
+
+// FindLeaf finds a directory of name leaf in the folder with ID directoryID
+func (f *Fs) FindLeaf(ctx context.Context, directoryID, leaf string) (directoryIDOut string, found bool, err error) {
+	// Find the leaf in directoryID
+	found, err = f.listAll(ctx, directoryID, leaf, func(entity *entity) bool {
+		if entity.isDir() && strings.EqualFold(entity.Name, leaf) {
+			directoryIDOut = itoa64(entity.ID)
+			return true
+		}
+		return false
+	})
+	return directoryIDOut, found, err
+}
+
+// Returned from "folder_create"
+type folderCreateRes struct {
+	response
+	Data struct {
+		DirID int64 `json:"dirId"`
+	} `json:"data"`
+}
+
+// CreateDir makes a directory with dirID as parent and name leaf
+func (f *Fs) CreateDir(ctx context.Context, dirID, leaf string) (newID string, err error) {
+	// fs.Debugf(f, "CreateDir(%q, %q)\n", dirID, leaf)
+	opts := &rest.Opts{
+		Method:  "GET",
+		RootURL: linkboxAPIURL,
+		Path:    "folder_create",
+		Parameters: url.Values{
+			"token":       {f.opt.Token},
+			"name":        {leaf},
+			"pid":         {dirID},
+			"isShare":     {"0"},
+			"canInvite":   {"1"},
+			"canShare":    {"1"},
+			"withBodyImg": {"1"},
+			"desc":        {""},
+		},
+	}
+
+	response := folderCreateRes{}
+	err = getUnmarshaledResponse(ctx, f, opts, &response)
+	if err != nil {
+		// response status 1501 means that directory already exists
+		if response.Status == 1501 {
+			return newID, fmt.Errorf("couldn't find already created directory: %w", fs.ErrorDirNotFound)
+		}
+		return newID, fmt.Errorf("CreateDir failed: %w", err)
+
+	}
+	if response.Data.DirID == 0 {
+		return newID, fmt.Errorf("API returned 0 for ID of newly created directory")
+	}
+	return itoa64(response.Data.DirID), nil
+}
+
+// List the objects and directories in dir into entries.  The
+// entries can be returned in any order but should be for a
+// complete directory.
+//
+// dir should be "" to list the root, and should not have
+// trailing slashes.
+//
+// This should return ErrDirNotFound if the directory isn't
+// found.
+func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
+	// fs.Debugf(f, "List method dir = {%s}", dir)
+	directoryID, err := f.dirCache.FindDir(ctx, dir, false)
+	if err != nil {
+		return nil, err
+	}
+	_, err = f.listAll(ctx, directoryID, "", func(entity *entity) bool {
+		remote := path.Join(dir, entity.Name)
+		if entity.isDir() {
+			id := itoa64(entity.ID)
+			modTime := time.Unix(entity.Ctime, 0)
+			d := fs.NewDir(remote, modTime).SetID(id).SetParentID(itoa64(entity.Pid))
+			entries = append(entries, d)
+			// cache the directory ID for later lookups
+			f.dirCache.Put(remote, id)
+		} else {
+			o := &Object{
+				fs:     f,
+				remote: remote,
+			}
+			o.set(entity)
+			entries = append(entries, o)
+		}
+		return false
+	})
+	if err != nil {
+		return nil, err
+	}
+	return entries, nil
+}
+
+// get an entity with leaf from dirID
+func getEntity(ctx context.Context, f *Fs, leaf string, directoryID string, token string) (*entity, error) {
+	var result *entity
+	var resultErr = fs.ErrorObjectNotFound
+	_, err := f.listAll(ctx, directoryID, leaf, func(entity *entity) bool {
+		if strings.EqualFold(entity.Name, leaf) {
+			// fs.Debugf(f, "getObject found entity.Name {%s} name {%s}", entity.Name, name)
+			if entity.isDir() {
+				result = nil
+				resultErr = fs.ErrorIsDir
+			} else {
+				result = entity
+				resultErr = nil
+			}
+			return true
+		}
+		return false
+	})
+	if err != nil {
+		return nil, err
+	}
+	return result, resultErr
+}
+
+// NewObject finds the Object at remote.  If it can't be found
+// it returns the error ErrorObjectNotFound.
+//
+// If remote points to a directory then it should return
+// ErrorIsDir if possible without doing any extra work,
+// otherwise ErrorObjectNotFound.
+func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
+	leaf, dirID, err := f.dirCache.FindPath(ctx, remote, false)
+	if err != nil {
+		if err == fs.ErrorDirNotFound {
+			return nil, fs.ErrorObjectNotFound
+		}
+		return nil, err
+	}
+
+	entity, err := getEntity(ctx, f, leaf, dirID, f.opt.Token)
+	if err != nil {
+		return nil, err
+	}
+	o := &Object{
+		fs:     f,
+		remote: remote,
+	}
+	o.set(entity)
+	return o, nil
+}
+
+// Mkdir makes the directory (container, bucket)
+//
+// Shouldn't return an error if it already exists
+func (f *Fs) Mkdir(ctx context.Context, dir string) error {
+	_, err := f.dirCache.FindDir(ctx, dir, true)
+	return err
+}
+
+func (f *Fs) purgeCheck(ctx context.Context, dir string, check bool) error {
+	if check {
+		entries, err := f.List(ctx, dir)
+		if err != nil {
+			return err
+		}
+		if len(entries) != 0 {
+			return fs.ErrorDirectoryNotEmpty
+		}
+	}
+
+	directoryID, err := f.dirCache.FindDir(ctx, dir, false)
+	if err != nil {
+		return err
+	}
+	opts := &rest.Opts{
+		Method:  "GET",
+		RootURL: linkboxAPIURL,
+		Path:    "folder_del",
+		Parameters: url.Values{
+			"token":  {f.opt.Token},
+			"dirIds": {directoryID},
+		},
+	}
+
+	response := response{}
+	err = getUnmarshaledResponse(ctx, f, opts, &response)
+	if err != nil {
+		// Linkbox has some odd error returns here
+		if response.Status == 403 || response.Status == 500 {
+			return fs.ErrorDirNotFound
+		}
+		return fmt.Errorf("purge error: %w", err)
+	}
+
+	f.dirCache.FlushDir(dir)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// Rmdir removes the directory (container, bucket) if empty
+//
+// Return an error if it doesn't exist or isn't empty
+func (f *Fs) Rmdir(ctx context.Context, dir string) error {
+	return f.purgeCheck(ctx, dir, true)
+}
+
+// SetModTime sets modTime on a particular file
+func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
+	return fs.ErrorCantSetModTime
+}
+
+// Open opens the file for read.  Call Close() on the returned io.ReadCloser
+func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
+	var res *http.Response
+	downloadURL := o.fullURL
+	if downloadURL == "" {
+		_, name := splitDirAndName(o.Remote())
+		newObject, err := getEntity(ctx, o.fs, name, itoa64(o.dirID), o.fs.opt.Token)
+		if err != nil {
+			return nil, err
+		}
+		if newObject == nil {
+			// fs.Debugf(o.fs, "Open entity is empty: name = {%s}", name)
+			return nil, fs.ErrorObjectNotFound
+		}
+
+		downloadURL = newObject.URL
+	}
+
+	opts := &rest.Opts{
+		Method:  "GET",
+		RootURL: downloadURL,
+		Options: options,
+	}
+
+	err := o.fs.pacer.Call(func() (bool, error) {
+		var err error
+		res, err = o.fs.srv.Call(ctx, opts)
+		return o.fs.shouldRetry(ctx, res, err)
+	})
+
+	if err != nil {
+		return nil, fmt.Errorf("Open failed: %w", err)
+	}
+
+	return res.Body, nil
+}
+
+// Update in to the object with the modTime given of the given size
+//
+// When called from outside an Fs by rclone, src.Size() will always be >= 0.
+// But for unknown-sized objects (indicated by src.Size() == -1), Upload should either
+// return an error or update the object properly (rather than e.g. calling panic).
+func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
+	size := src.Size()
+	if size == 0 {
+		return fs.ErrorCantUploadEmptyFiles
+	} else if size < 0 {
+		return fmt.Errorf("can't upload files of unknown length")
+	}
+
+	remote := o.Remote()
+
+	// remove the file if it exists
+	if o.itemID != "" {
+		fs.Debugf(o, "Update: removing old file")
+		err = o.Remove(ctx)
+		if err != nil {
+			fs.Errorf(o, "Update: failed to remove existing file: %v", err)
+		}
+		o.itemID = ""
+	} else {
+		tmpObject, err := o.fs.NewObject(ctx, remote)
+		if err == nil {
+			fs.Debugf(o, "Update: removing old file")
+			err = tmpObject.Remove(ctx)
+			if err != nil {
+				fs.Errorf(o, "Update: failed to remove existing file: %v", err)
+			}
+		}
+	}
+
+	first10m := io.LimitReader(in, 10_485_760)
+	first10mBytes, err := io.ReadAll(first10m)
+	if err != nil {
+		return fmt.Errorf("Update err in reading file: %w", err)
+	}
+
+	// get upload authorization (step 1)
+	opts := &rest.Opts{
+		Method:  "GET",
+		RootURL: linkboxAPIURL,
+		Path:    "get_upload_url",
+		Options: options,
+		Parameters: url.Values{
+			"token":           {o.fs.opt.Token},
+			"fileMd5ofPre10m": {fmt.Sprintf("%x", md5.Sum(first10mBytes))},
+			"fileSize":        {itoa64(size)},
+		},
+	}
+
+	getFirstStepResult := getUploadURLResponse{}
+	err = getUnmarshaledResponse(ctx, o.fs, opts, &getFirstStepResult)
+	if err != nil {
+		if getFirstStepResult.Status != 600 {
+			return fmt.Errorf("Update err in unmarshaling response: %w", err)
+		}
+	}
+
+	switch getFirstStepResult.Status {
+	case 1:
+		// upload file using link from first step
+		var res *http.Response
+
+		file := io.MultiReader(bytes.NewReader(first10mBytes), in)
+
+		opts := &rest.Opts{
+			Method:        "PUT",
+			RootURL:       getFirstStepResult.Data.SignURL,
+			Options:       options,
+			Body:          file,
+			ContentLength: &size,
+		}
+
+		err = o.fs.pacer.CallNoRetry(func() (bool, error) {
+			res, err = o.fs.srv.Call(ctx, opts)
+			return o.fs.shouldRetry(ctx, res, err)
+		})
+
+		if err != nil {
+			return fmt.Errorf("update err in uploading file: %w", err)
+		}
+
+		_, err = io.ReadAll(res.Body)
+		if err != nil {
+			return fmt.Errorf("update err in reading response: %w", err)
+		}
+
+	case 600:
+		// Status means that we don't need to upload file
+		// We need only to make second step
+	default:
+		return fmt.Errorf("got unexpected message from Linkbox: %s", getFirstStepResult.Message)
+	}
+
+	leaf, dirID, err := o.fs.dirCache.FindPath(ctx, remote, false)
+	if err != nil {
+		return err
+	}
+
+	// create file item at Linkbox (second step)
+	opts = &rest.Opts{
+		Method:  "GET",
+		RootURL: linkboxAPIURL,
+		Path:    "folder_upload_file",
+		Options: options,
+		Parameters: url.Values{
+			"token":           {o.fs.opt.Token},
+			"fileMd5ofPre10m": {fmt.Sprintf("%x", md5.Sum(first10mBytes))},
+			"fileSize":        {itoa64(size)},
+			"pid":             {dirID},
+			"diyName":         {leaf},
+		},
+	}
+
+	getSecondStepResult := getUploadURLResponse{}
+	err = getUnmarshaledResponse(ctx, o.fs, opts, &getSecondStepResult)
+	if err != nil {
+		return fmt.Errorf("Update second step failed: %w", err)
+	}
+
+	// Try a few times to read the object after upload for eventual consistency
+	const maxTries = 10
+	var sleepTime = 100 * time.Millisecond
+	var entity *entity
+	for try := 1; try <= maxTries; try++ {
+		entity, err = getEntity(ctx, o.fs, leaf, dirID, o.fs.opt.Token)
+		if err == nil {
+			break
+		}
+		if err != fs.ErrorObjectNotFound {
+			return fmt.Errorf("Update failed to read object: %w", err)
+		}
+		fs.Debugf(o, "Trying to read object after upload: try again in %v (%d/%d)", sleepTime, try, maxTries)
+		time.Sleep(sleepTime)
+		sleepTime *= 2
+	}
+	if err != nil {
+		return err
+	}
+	o.set(entity)
+	return nil
+}
+
+// Remove this object
+func (o *Object) Remove(ctx context.Context) error {
+	opts := &rest.Opts{
+		Method:  "GET",
+		RootURL: linkboxAPIURL,
+		Path:    "file_del",
+		Parameters: url.Values{
+			"token":   {o.fs.opt.Token},
+			"itemIds": {o.itemID},
+		},
+	}
+	requestResult := getUploadURLResponse{}
+	err := getUnmarshaledResponse(ctx, o.fs, opts, &requestResult)
+	if err != nil {
+		return fmt.Errorf("could not Remove: %w", err)
+
+	}
+	return nil
+}
+
+// ModTime returns the modification time of the remote http file
+func (o *Object) ModTime(ctx context.Context) time.Time {
+	return o.modTime
+}
+
+// Remote the name of the remote HTTP file, relative to the fs root
+func (o *Object) Remote() string {
+	return o.remote
+}
+
+// Size returns the size in bytes of the remote http file
+func (o *Object) Size() int64 {
+	return o.size
+}
+
+// String returns the URL to the remote HTTP file
+func (o *Object) String() string {
+	if o == nil {
+		return "<nil>"
+	}
+	return o.remote
+}
+
+// Fs is the filesystem this remote http file object is located within
+func (o *Object) Fs() fs.Info {
+	return o.fs
+}
+
+// Hash returns "" since HTTP (in Go or OpenSSH) doesn't support remote calculation of hashes
+func (o *Object) Hash(ctx context.Context, r hash.Type) (string, error) {
+	return "", hash.ErrUnsupported
+}
+
+// Storable returns whether the remote http file is a regular file
+// (not a directory, symbolic link, block device, character device, named pipe, etc.)
+func (o *Object) Storable() bool {
+	return true
+}
+
+// Features returns the optional features of this Fs
+// Info provides a read only interface to information about a filesystem.
+func (f *Fs) Features() *fs.Features {
+	return f.features
+}
+
+// Name of the remote (as passed into NewFs)
+// Name returns the configured name of the file system
+func (f *Fs) Name() string {
+	return f.name
+}
+
+// Root of the remote (as passed into NewFs)
+func (f *Fs) Root() string {
+	return f.root
+}
+
+// String returns a description of the FS
+func (f *Fs) String() string {
+	return fmt.Sprintf("Linkbox root '%s'", f.root)
+}
+
+// Precision of the ModTimes in this Fs
+func (f *Fs) Precision() time.Duration {
+	return fs.ModTimeNotSupported
+}
+
+// Hashes returns hash.HashNone to indicate remote hashing is unavailable
+// Returns the supported hash types of the filesystem
+func (f *Fs) Hashes() hash.Set {
+	return hash.Set(hash.None)
+}
+
+/*
+	{
+	  "data": {
+	    "signUrl": "http://xx -- Then CURL PUT your file with sign url "
+	  },
+	  "msg": "please use this url to upload (PUT method)",
+	  "status": 1
+	}
+*/
+
+// All messages have these items
+type response struct {
+	Message string `json:"msg"`
+	Status  int    `json:"status"`
+}
+
+// IsError returns whether response represents an error
+func (r *response) IsError() bool {
+	return r.Status != 1
+}
+
+// Error returns the error state of this response
+func (r *response) Error() string {
+	return fmt.Sprintf("Linkbox error %d: %s", r.Status, r.Message)
+}
+
+// responser is interface covering the response so we can use it when it is embedded.
+type responser interface {
+	IsError() bool
+	Error() string
+}
+
+type getUploadURLData struct {
+	SignURL string `json:"signUrl"`
+}
+
+type getUploadURLResponse struct {
+	response
+	Data getUploadURLData `json:"data"`
+}
+
+// Put in to the remote path with the modTime given of the given size
+//
+// When called from outside an Fs by rclone, src.Size() will always be >= 0.
+// But for unknown-sized objects (indicated by src.Size() == -1), Put should either
+// return an error or upload it properly (rather than e.g. calling panic).
+//
+// May create the object even if it returns an error - if so
+// will return the object and the error, otherwise will return
+// nil and the error
+func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
+	o := &Object{
+		fs:     f,
+		remote: src.Remote(),
+		size:   src.Size(),
+	}
+	dir, _ := splitDirAndName(src.Remote())
+	err := f.Mkdir(ctx, dir)
+	if err != nil {
+		return nil, err
+	}
+	err = o.Update(ctx, in, src, options...)
+	return o, err
+}
+
+// Purge all files in the directory specified
+//
+// Implement this if you have a way of deleting all the files
+// quicker than just running Remove() on the result of List()
+//
+// Return an error if it doesn't exist
+func (f *Fs) Purge(ctx context.Context, dir string) error {
+	return f.purgeCheck(ctx, dir, false)
+}
+
+// retryErrorCodes is a slice of error codes that we will retry
+var retryErrorCodes = []int{
+	429, // Too Many Requests.
+	500, // Internal Server Error
+	502, // Bad Gateway
+	503, // Service Unavailable
+	504, // Gateway Timeout
+	509, // Bandwidth Limit Exceeded
+}
+
+// shouldRetry determines whether a given err rates being retried
+func (f *Fs) shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
+	if fserrors.ContextError(ctx, &err) {
+		return false, err
+	}
+	return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
+}
+
+// DirCacheFlush resets the directory cache - used in testing as an
+// optional interface
+func (f *Fs) DirCacheFlush() {
+	f.dirCache.ResetRoot()
+}
+
+// Check the interfaces are satisfied
+var (
+	_ fs.Fs              = &Fs{}
+	_ fs.Purger          = &Fs{}
+	_ fs.DirCacheFlusher = &Fs{}
+	_ fs.Object          = &Object{}
+)
diff --git a/backend/local/local.go b/backend/local/local.go
index e10980a3c..c444267f1 100644
--- a/backend/local/local.go
+++ b/backend/local/local.go
@@ -1298,7 +1298,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	}
 
 	// Fetch and set metadata if --metadata is in use
-	meta, err := fs.GetMetadataOptions(ctx, src, options)
+	meta, err := fs.GetMetadataOptions(ctx, o.fs, src, options)
 	if err != nil {
 		return fmt.Errorf("failed to read metadata from source object: %w", err)
 	}
diff --git a/backend/oracleobjectstorage/multipart.go b/backend/oracleobjectstorage/multipart.go
index 8555e203a..d57a9092a 100644
--- a/backend/oracleobjectstorage/multipart.go
+++ b/backend/oracleobjectstorage/multipart.go
@@ -295,7 +295,7 @@ func (o *Object) prepareUpload(ctx context.Context, src fs.ObjectInfo, options [
 	// Set the mtime in the metadata
 	modTime := src.ModTime(ctx)
 	// Fetch metadata if --metadata is in use
-	meta, err := fs.GetMetadataOptions(ctx, src, options)
+	meta, err := fs.GetMetadataOptions(ctx, o.fs, src, options)
 	if err != nil {
 		return ui, fmt.Errorf("failed to read metadata from source object: %w", err)
 	}
diff --git a/backend/s3/s3.go b/backend/s3/s3.go
index 070246007..d4f686735 100644
--- a/backend/s3/s3.go
+++ b/backend/s3/s3.go
@@ -6008,7 +6008,7 @@ func (o *Object) prepareUpload(ctx context.Context, src fs.ObjectInfo, options [
 	}
 
 	// Fetch metadata if --metadata is in use
-	meta, err := fs.GetMetadataOptions(ctx, src, options)
+	meta, err := fs.GetMetadataOptions(ctx, o.fs, src, options)
 	if err != nil {
 		return ui, fmt.Errorf("failed to read metadata from source object: %w", err)
 	}
diff --git a/bin/make_manual.py b/bin/make_manual.py
index 19bb88cc0..9e8557cbf 100755
--- a/bin/make_manual.py
+++ b/bin/make_manual.py
@@ -53,11 +53,13 @@ docs = [
     "internetarchive.md",
     "jottacloud.md",
     "koofr.md",
+    "linkbox.md"
     "mailru.md",
     "mega.md",
     "memory.md",
     "netstorage.md",
     "azureblob.md",
+    "azurefiles.md",
     "onedrive.md",
     "opendrive.md",
     "oracleobjectstorage.md",
diff --git a/docs/content/_index.md b/docs/content/_index.md
index 5c6c2b86f..a74913751 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -138,12 +138,14 @@ WebDAV or S3, that work out of the box.)
 {{< provider name="Koofr" home="https://koofr.eu/" config="/koofr/" >}}
 {{< 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-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="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/" >}}
 {{< provider name="Memory" home="/memory/" config="/memory/" >}}
 {{< provider name="Microsoft Azure Blob Storage" home="https://azure.microsoft.com/en-us/services/storage/blobs/" config="/azureblob/" >}}
+{{< provider name="Microsoft Azure Files Storage" home="https://azure.microsoft.com/en-us/services/storage/files/" config="/azurefiles/" >}}
 {{< provider name="Microsoft OneDrive" home="https://onedrive.live.com/" config="/onedrive/" >}}
 {{< provider name="Minio" home="https://www.minio.io/" config="/s3/#minio" >}}
 {{< provider name="Nextcloud" home="https://nextcloud.com/" config="/webdav/#nextcloud" >}}
diff --git a/docs/content/amazonclouddrive.md b/docs/content/amazonclouddrive.md
index b0068f94f..d7ddec191 100644
--- a/docs/content/amazonclouddrive.md
+++ b/docs/content/amazonclouddrive.md
@@ -127,13 +127,13 @@ To copy a local directory to an Amazon Drive directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and MD5SUMs
+### Modification times and hashes
 
 Amazon Drive doesn't allow modification times to be changed via
 the API so these won't be accurate or used for syncing.
 
-It does store MD5SUMs so for a more accurate sync, you can use the
-`--checksum` flag.
+It does support the MD5 hash algorithm, so for a more accurate sync,
+you can use the `--checksum` flag.
 
 ### Restricted filename characters
 
diff --git a/docs/content/authors.md b/docs/content/authors.md
index 3383bfbc0..90d0240c5 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -797,3 +797,8 @@ put them back in again.` >}}
   * Mikubill <31246794+Mikubill@users.noreply.github.com>
   * Artur Neumann <artur@jankaritech.com>
   * Saw-jan <saw.jan.grg3e@gmail.com>
+  * Oksana Zhykina <o.zhykina@maytech.net>
+  * karan <karan.gupta92@gmail.com>
+  * viktor <viktor@yakovchuk.net>
+  * moongdal <moongdal@tutanota.com>
+  * Mina Galić <freebsd@igalic.co>
diff --git a/docs/content/azureblob.md b/docs/content/azureblob.md
index 937824f11..6436c5d23 100644
--- a/docs/content/azureblob.md
+++ b/docs/content/azureblob.md
@@ -75,10 +75,10 @@ This remote supports `--fast-list` which allows you to use fewer
 transactions in exchange for more memory. See the [rclone
 docs](/docs/#fast-list) for more details.
 
-### Modified time
+### Modification times and hashes
 
-The modified time is stored as metadata on the object with the `mtime`
-key.  It is stored using RFC3339 Format time with nanosecond
+The modification time is stored as metadata on the object with the
+`mtime` key.  It is stored using RFC3339 Format time with nanosecond
 precision.  The metadata is supplied during directory listings so
 there is no performance overhead to using it.
 
@@ -88,6 +88,10 @@ flag. Note that rclone can't set `LastModified`, so using the
 `--update` flag when syncing is recommended if using
 `--use-server-modtime`.
 
+MD5 hashes are stored with blobs. However blobs that were uploaded in
+chunks only have an MD5 if the source remote was capable of MD5
+hashes, e.g. the local disk.
+
 ### Performance
 
 When uploading large files, increasing the value of
@@ -116,12 +120,6 @@ These only get replaced if they are the last character in the name:
 Invalid UTF-8 bytes will also be [replaced](/overview/#invalid-utf8),
 as they can't be used in JSON strings.
 
-### Hashes
-
-MD5 hashes are stored with blobs.  However blobs that were uploaded in
-chunks only have an MD5 if the source remote was capable of MD5
-hashes, e.g. the local disk.
-
 ### Authentication {#authentication}
 
 There are a number of ways of supplying credentials for Azure Blob
diff --git a/docs/content/azurefiles.md b/docs/content/azurefiles.md
new file mode 100644
index 000000000..a6e84f2e0
--- /dev/null
+++ b/docs/content/azurefiles.md
@@ -0,0 +1,707 @@
+---
+title: "Microsoft Azure Files Storage"
+description: "Rclone docs for Microsoft Azure Files Storage"
+versionIntroduced: "v1.65"
+---
+
+# {{< icon "fab fa-windows" >}} Microsoft Azure Files Storage
+
+Paths are specified as `remote:` You may put subdirectories in too,
+e.g. `remote:path/to/dir`.
+
+## Configuration
+
+Here is an example of making a Microsoft Azure Files Storage
+configuration.  For a remote called `remote`.  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
+name> remote
+Type of storage to configure.
+Choose a number from below, or type in your own value
+[snip]
+XX / Microsoft Azure Files Storage
+   \ "azurefiles"
+[snip]
+
+Option account.
+Azure Storage Account Name.
+Set this to the Azure Storage Account Name in use.
+Leave blank to use SAS URL or connection string, otherwise it needs to be set.
+If this is blank and if env_auth is set it will be read from the
+environment variable `AZURE_STORAGE_ACCOUNT_NAME` if possible.
+Enter a value. Press Enter to leave empty.
+account> account_name
+
+Option share_name.
+Azure Files Share Name.
+This is required and is the name of the share to access.
+Enter a value. Press Enter to leave empty.
+share_name> share_name
+
+Option env_auth.
+Read credentials from runtime (environment variables, CLI or MSI).
+See the [authentication docs](/azurefiles#authentication) for full info.
+Enter a boolean value (true or false). Press Enter for the default (false).
+env_auth> 
+
+Option key.
+Storage Account Shared Key.
+Leave blank to use SAS URL or connection string.
+Enter a value. Press Enter to leave empty.
+key> base64encodedkey==
+
+Option sas_url.
+SAS URL.
+Leave blank if using account/key or connection string.
+Enter a value. Press Enter to leave empty.
+sas_url> 
+
+Option connection_string.
+Azure Files Connection String.
+Enter a value. Press Enter to leave empty.
+connection_string> 
+[snip]
+
+Configuration complete.
+Options:
+- type: azurefiles
+- account: account_name
+- share_name: share_name
+- key: base64encodedkey==
+Keep this "remote" remote?
+y) Yes this is OK (default)
+e) Edit this remote
+d) Delete this remote
+y/e/d> 
+```
+
+Once configured you can use rclone.
+
+See all files in the top level:
+
+    rclone lsf remote:
+
+Make a new directory in the root:
+
+    rclone mkdir remote:dir
+
+Recursively List the contents:
+
+    rclone ls remote:
+
+Sync `/home/local/directory` to the remote directory, deleting any
+excess files in the directory.
+
+    rclone sync --interactive /home/local/directory remote:dir
+
+### Modified time
+
+The modified time is stored as Azure standard `LastModified` time on
+files
+
+### Performance
+
+When uploading large files, increasing the value of
+`--azurefiles-upload-concurrency` will increase performance at the cost
+of using more memory. The default of 16 is set quite conservatively to
+use less memory. It maybe be necessary raise it to 64 or higher to
+fully utilize a 1 GBit/s link with a single file transfer.
+
+### Restricted filename characters
+
+In addition to the [default restricted characters set](/overview/#restricted-characters)
+the following characters are also replaced:
+
+| Character | Value | Replacement |
+| --------- |:-----:|:-----------:|
+| "         | 0x22  | ＂          |
+| *         | 0x2A  | ＊          |
+| :         | 0x3A  | ：          |
+| <         | 0x3C  | ＜          |
+| >         | 0x3E  | ＞          |
+| ?         | 0x3F  | ？          |
+| \         | 0x5C  | ＼          |
+| \|        | 0x7C  | ｜          |
+
+File names can also not end with the following characters.
+These only get replaced if they are the last character in the name:
+
+| Character | Value | Replacement |
+| --------- |:-----:|:-----------:|
+| .         | 0x2E  | ．          |
+
+Invalid UTF-8 bytes will also be [replaced](/overview/#invalid-utf8),
+as they can't be used in JSON strings.
+
+### Hashes
+
+MD5 hashes are stored with files. Not all files will have MD5 hashes
+as these have to be uploaded with the file.
+
+### Authentication {#authentication}
+
+There are a number of ways of supplying credentials for Azure Files
+Storage. Rclone tries them in the order of the sections below.
+
+#### Env Auth
+
+If the `env_auth` config parameter is `true` then rclone will pull
+credentials from the environment or runtime.
+
+It tries these authentication methods in this order:
+
+1. Environment Variables
+2. Managed Service Identity Credentials
+3. Azure CLI credentials (as used by the az tool)
+
+These are described in the following sections
+
+##### Env Auth: 1. Environment Variables
+
+If `env_auth` is set and environment variables are present rclone
+authenticates a service principal with a secret or certificate, or a
+user with a password, depending on which environment variable are set.
+It reads configuration from these variables, in the following order:
+
+1. Service principal with client secret
+    - `AZURE_TENANT_ID`: ID of the service principal's tenant. Also called its "directory" ID.
+    - `AZURE_CLIENT_ID`: the service principal's client ID
+    - `AZURE_CLIENT_SECRET`: one of the service principal's client secrets
+2. Service principal with certificate
+    - `AZURE_TENANT_ID`: ID of the service principal's tenant. Also called its "directory" ID.
+    - `AZURE_CLIENT_ID`: the service principal's client ID
+    - `AZURE_CLIENT_CERTIFICATE_PATH`: path to a PEM or PKCS12 certificate file including the private key.
+    - `AZURE_CLIENT_CERTIFICATE_PASSWORD`: (optional) password for the certificate file.
+    - `AZURE_CLIENT_SEND_CERTIFICATE_CHAIN`: (optional) Specifies whether an authentication request will include an x5c header to support subject name / issuer based authentication. When set to "true" or "1", authentication requests include the x5c header.
+3. User with username and password
+    - `AZURE_TENANT_ID`: (optional) tenant to authenticate in. Defaults to "organizations".
+    - `AZURE_CLIENT_ID`: client ID of the application the user will authenticate to
+    - `AZURE_USERNAME`: a username (usually an email address)
+    - `AZURE_PASSWORD`: the user's password
+4. Workload Identity
+    - `AZURE_TENANT_ID`: Tenant to authenticate in.
+    - `AZURE_CLIENT_ID`: Client ID of the application the user will authenticate to.
+    - `AZURE_FEDERATED_TOKEN_FILE`: Path to projected service account token file.
+    - `AZURE_AUTHORITY_HOST`: Authority of an Azure Active Directory endpoint (default: login.microsoftonline.com).
+
+
+##### Env Auth: 2. Managed Service Identity Credentials
+
+When using Managed Service Identity if the VM(SS) on which this
+program is running has a system-assigned identity, it will be used by
+default. If the resource has no system-assigned but exactly one
+user-assigned identity, the user-assigned identity will be used by
+default.
+
+If the resource has multiple user-assigned identities you will need to
+unset `env_auth` and set `use_msi` instead. See the [`use_msi`
+section](#use_msi).
+
+##### Env Auth: 3. Azure CLI credentials (as used by the az tool)
+
+Credentials created with the `az` tool can be picked up using `env_auth`.
+
+For example if you were to login with a service principal like this:
+
+    az login --service-principal -u XXX -p XXX --tenant XXX
+
+Then you could access rclone resources like this:
+
+    rclone lsf :azurefiles,env_auth,account=ACCOUNT:
+
+Or
+
+    rclone lsf --azurefiles-env-auth --azurefiles-account=ACCOUNT :azurefiles:
+
+#### Account and Shared Key
+
+This is the most straight forward and least flexible way.  Just fill
+in the `account` and `key` lines and leave the rest blank.
+
+#### SAS URL
+
+To use it leave `account`, `key` and `connection_string` blank and fill in `sas_url`.
+
+#### Connection String
+
+To use it leave `account`, `key` and "sas_url" blank and fill in `connection_string`.
+
+#### Service principal with client secret
+
+If these variables are set, rclone will authenticate with a service principal with a client secret.
+
+- `tenant`: ID of the service principal's tenant. Also called its "directory" ID.
+- `client_id`: the service principal's client ID
+- `client_secret`: one of the service principal's client secrets
+
+The credentials can also be placed in a file using the
+`service_principal_file` configuration option.
+
+#### Service principal with certificate
+
+If these variables are set, rclone will authenticate with a service principal with certificate.
+
+- `tenant`: ID of the service principal's tenant. Also called its "directory" ID.
+- `client_id`: the service principal's client ID
+- `client_certificate_path`: path to a PEM or PKCS12 certificate file including the private key.
+- `client_certificate_password`: (optional) password for the certificate file.
+- `client_send_certificate_chain`: (optional) Specifies whether an authentication request will include an x5c header to support subject name / issuer based authentication. When set to "true" or "1", authentication requests include the x5c header.
+
+**NB** `client_certificate_password` must be obscured - see [rclone obscure](/commands/rclone_obscure/).
+
+#### User with username and password
+
+If these variables are set, rclone will authenticate with username and password.
+
+- `tenant`: (optional) tenant to authenticate in. Defaults to "organizations".
+- `client_id`: client ID of the application the user will authenticate to
+- `username`: a username (usually an email address)
+- `password`: the user's password
+
+Microsoft doesn't recommend this kind of authentication, because it's
+less secure than other authentication flows. This method is not
+interactive, so it isn't compatible with any form of multi-factor
+authentication, and the application must already have user or admin
+consent. This credential can only authenticate work and school
+accounts; it can't authenticate Microsoft accounts.
+
+**NB** `password` must be obscured - see [rclone obscure](/commands/rclone_obscure/).
+
+#### Managed Service Identity Credentials {#use_msi}
+
+If `use_msi` is set then managed service identity credentials are
+used. This authentication only works when running in an Azure service.
+`env_auth` needs to be unset to use this.
+
+However if you have multiple user identities to choose from these must
+be explicitly specified using exactly one of the `msi_object_id`,
+`msi_client_id`, or `msi_mi_res_id` parameters.
+
+If none of `msi_object_id`, `msi_client_id`, or `msi_mi_res_id` is
+set, this is is equivalent to using `env_auth`.
+
+{{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/azurefiles/azurefiles.go then run make backenddocs" >}}
+### Standard options
+
+Here are the Standard options specific to azurefiles (Microsoft Azure Files).
+
+#### --azurefiles-account
+
+Azure Storage Account Name.
+
+Set this to the Azure Storage Account Name in use.
+
+Leave blank to use SAS URL or connection string, otherwise it needs to be set.
+
+If this is blank and if env_auth is set it will be read from the
+environment variable `AZURE_STORAGE_ACCOUNT_NAME` if possible.
+
+
+Properties:
+
+- Config:      account
+- Env Var:     RCLONE_AZUREFILES_ACCOUNT
+- Type:        string
+- Required:    false
+
+#### --azurefiles-share-name
+
+Azure Files Share Name.
+
+This is required and is the name of the share to access.
+
+
+Properties:
+
+- Config:      share_name
+- Env Var:     RCLONE_AZUREFILES_SHARE_NAME
+- Type:        string
+- Required:    false
+
+#### --azurefiles-env-auth
+
+Read credentials from runtime (environment variables, CLI or MSI).
+
+See the [authentication docs](/azurefiles#authentication) for full info.
+
+Properties:
+
+- Config:      env_auth
+- Env Var:     RCLONE_AZUREFILES_ENV_AUTH
+- Type:        bool
+- Default:     false
+
+#### --azurefiles-key
+
+Storage Account Shared Key.
+
+Leave blank to use SAS URL or connection string.
+
+Properties:
+
+- Config:      key
+- Env Var:     RCLONE_AZUREFILES_KEY
+- Type:        string
+- Required:    false
+
+#### --azurefiles-sas-url
+
+SAS URL.
+
+Leave blank if using account/key or connection string.
+
+Properties:
+
+- Config:      sas_url
+- Env Var:     RCLONE_AZUREFILES_SAS_URL
+- Type:        string
+- Required:    false
+
+#### --azurefiles-connection-string
+
+Azure Files Connection String.
+
+Properties:
+
+- Config:      connection_string
+- Env Var:     RCLONE_AZUREFILES_CONNECTION_STRING
+- Type:        string
+- Required:    false
+
+#### --azurefiles-tenant
+
+ID of the service principal's tenant. Also called its directory ID.
+
+Set this if using
+- Service principal with client secret
+- Service principal with certificate
+- User with username and password
+
+
+Properties:
+
+- Config:      tenant
+- Env Var:     RCLONE_AZUREFILES_TENANT
+- Type:        string
+- Required:    false
+
+#### --azurefiles-client-id
+
+The ID of the client in use.
+
+Set this if using
+- Service principal with client secret
+- Service principal with certificate
+- User with username and password
+
+
+Properties:
+
+- Config:      client_id
+- Env Var:     RCLONE_AZUREFILES_CLIENT_ID
+- Type:        string
+- Required:    false
+
+#### --azurefiles-client-secret
+
+One of the service principal's client secrets
+
+Set this if using
+- Service principal with client secret
+
+
+Properties:
+
+- Config:      client_secret
+- Env Var:     RCLONE_AZUREFILES_CLIENT_SECRET
+- Type:        string
+- Required:    false
+
+#### --azurefiles-client-certificate-path
+
+Path to a PEM or PKCS12 certificate file including the private key.
+
+Set this if using
+- Service principal with certificate
+
+
+Properties:
+
+- Config:      client_certificate_path
+- Env Var:     RCLONE_AZUREFILES_CLIENT_CERTIFICATE_PATH
+- Type:        string
+- Required:    false
+
+#### --azurefiles-client-certificate-password
+
+Password for the certificate file (optional).
+
+Optionally set this if using
+- Service principal with certificate
+
+And the certificate has a password.
+
+
+**NB** Input to this must be obscured - see [rclone obscure](/commands/rclone_obscure/).
+
+Properties:
+
+- Config:      client_certificate_password
+- Env Var:     RCLONE_AZUREFILES_CLIENT_CERTIFICATE_PASSWORD
+- Type:        string
+- Required:    false
+
+### Advanced options
+
+Here are the Advanced options specific to azurefiles (Microsoft Azure Files).
+
+#### --azurefiles-client-send-certificate-chain
+
+Send the certificate chain when using certificate auth.
+
+Specifies whether an authentication request will include an x5c header
+to support subject name / issuer based authentication. When set to
+true, authentication requests include the x5c header.
+
+Optionally set this if using
+- Service principal with certificate
+
+
+Properties:
+
+- Config:      client_send_certificate_chain
+- Env Var:     RCLONE_AZUREFILES_CLIENT_SEND_CERTIFICATE_CHAIN
+- Type:        bool
+- Default:     false
+
+#### --azurefiles-username
+
+User name (usually an email address)
+
+Set this if using
+- User with username and password
+
+
+Properties:
+
+- Config:      username
+- Env Var:     RCLONE_AZUREFILES_USERNAME
+- Type:        string
+- Required:    false
+
+#### --azurefiles-password
+
+The user's password
+
+Set this if using
+- User with username and password
+
+
+**NB** Input to this must be obscured - see [rclone obscure](/commands/rclone_obscure/).
+
+Properties:
+
+- Config:      password
+- Env Var:     RCLONE_AZUREFILES_PASSWORD
+- Type:        string
+- Required:    false
+
+#### --azurefiles-service-principal-file
+
+Path to file containing credentials for use with a service principal.
+
+Leave blank normally. Needed only if you want to use a service principal instead of interactive login.
+
+    $ az ad sp create-for-rbac --name "<name>" \
+      --role "Storage Files Data Owner" \
+      --scopes "/subscriptions/<subscription>/resourceGroups/<resource-group>/providers/Microsoft.Storage/storageAccounts/<storage-account>/blobServices/default/containers/<container>" \
+      > azure-principal.json
+
+See ["Create an Azure service principal"](https://docs.microsoft.com/en-us/cli/azure/create-an-azure-service-principal-azure-cli) and ["Assign an Azure role for access to files data"](https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad-rbac-cli) pages for more details.
+
+**NB** this section needs updating for Azure Files - pull requests appreciated!
+
+It may be more convenient to put the credentials directly into the
+rclone config file under the `client_id`, `tenant` and `client_secret`
+keys instead of setting `service_principal_file`.
+
+
+Properties:
+
+- Config:      service_principal_file
+- Env Var:     RCLONE_AZUREFILES_SERVICE_PRINCIPAL_FILE
+- Type:        string
+- Required:    false
+
+#### --azurefiles-use-msi
+
+Use a managed service identity to authenticate (only works in Azure).
+
+When true, use a [managed service identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/)
+to authenticate to Azure Storage instead of a SAS token or account key.
+
+If the VM(SS) on which this program is running has a system-assigned identity, it will
+be used by default. If the resource has no system-assigned but exactly one user-assigned identity,
+the user-assigned identity will be used by default. If the resource has multiple user-assigned
+identities, the identity to use must be explicitly specified using exactly one of the msi_object_id,
+msi_client_id, or msi_mi_res_id parameters.
+
+Properties:
+
+- Config:      use_msi
+- Env Var:     RCLONE_AZUREFILES_USE_MSI
+- Type:        bool
+- Default:     false
+
+#### --azurefiles-msi-object-id
+
+Object ID of the user-assigned MSI to use, if any.
+
+Leave blank if msi_client_id or msi_mi_res_id specified.
+
+Properties:
+
+- Config:      msi_object_id
+- Env Var:     RCLONE_AZUREFILES_MSI_OBJECT_ID
+- Type:        string
+- Required:    false
+
+#### --azurefiles-msi-client-id
+
+Object ID of the user-assigned MSI to use, if any.
+
+Leave blank if msi_object_id or msi_mi_res_id specified.
+
+Properties:
+
+- Config:      msi_client_id
+- Env Var:     RCLONE_AZUREFILES_MSI_CLIENT_ID
+- Type:        string
+- Required:    false
+
+#### --azurefiles-msi-mi-res-id
+
+Azure resource ID of the user-assigned MSI to use, if any.
+
+Leave blank if msi_client_id or msi_object_id specified.
+
+Properties:
+
+- Config:      msi_mi_res_id
+- Env Var:     RCLONE_AZUREFILES_MSI_MI_RES_ID
+- Type:        string
+- Required:    false
+
+#### --azurefiles-endpoint
+
+Endpoint for the service.
+
+Leave blank normally.
+
+Properties:
+
+- Config:      endpoint
+- Env Var:     RCLONE_AZUREFILES_ENDPOINT
+- Type:        string
+- Required:    false
+
+#### --azurefiles-chunk-size
+
+Upload chunk size.
+
+Note that this is stored in memory and there may be up to
+"--transfers" * "--azurefile-upload-concurrency" chunks stored at once
+in memory.
+
+Properties:
+
+- Config:      chunk_size
+- Env Var:     RCLONE_AZUREFILES_CHUNK_SIZE
+- Type:        SizeSuffix
+- Default:     4Mi
+
+#### --azurefiles-upload-concurrency
+
+Concurrency for multipart uploads.
+
+This is the number of chunks of the same file that are uploaded
+concurrently.
+
+If you are uploading small numbers of large files over high-speed
+links and these uploads do not fully utilize your bandwidth, then
+increasing this may help to speed up the transfers.
+
+Note that chunks are stored in memory and there may be up to
+"--transfers" * "--azurefile-upload-concurrency" chunks stored at once
+in memory.
+
+Properties:
+
+- Config:      upload_concurrency
+- Env Var:     RCLONE_AZUREFILES_UPLOAD_CONCURRENCY
+- Type:        int
+- Default:     16
+
+#### --azurefiles-max-stream-size
+
+Max size for streamed files.
+
+Azure files needs to know in advance how big the file will be. When
+rclone doesn't know it uses this value instead.
+
+This will be used when rclone is streaming data, the most common uses are:
+
+- Uploading files with `--vfs-cache-mode off` with `rclone mount`
+- Using `rclone rcat`
+- Copying files with unknown length
+
+You will need this much free space in the share as the file will be this size temporarily.
+
+
+Properties:
+
+- Config:      max_stream_size
+- Env Var:     RCLONE_AZUREFILES_MAX_STREAM_SIZE
+- Type:        SizeSuffix
+- Default:     10Gi
+
+#### --azurefiles-encoding
+
+The encoding for the backend.
+
+See the [encoding section in the overview](/overview/#encoding) for more info.
+
+Properties:
+
+- Config:      encoding
+- Env Var:     RCLONE_AZUREFILES_ENCODING
+- Type:        Encoding
+- Default:     Slash,LtGt,DoubleQuote,Colon,Question,Asterisk,Pipe,BackSlash,Del,Ctl,RightPeriod,InvalidUtf8,Dot
+
+{{< rem autogenerated options stop >}}
+
+### Custom upload headers
+
+You can set custom upload headers with the `--header-upload` flag. 
+
+- Cache-Control
+- Content-Disposition
+- Content-Encoding
+- Content-Language
+- Content-Type
+
+Eg `--header-upload "Content-Type: text/potato"`
+
+## Limitations
+
+MD5 sums are only uploaded with chunked files if the source has an MD5
+sum.  This will always be the case for a local to azure copy.
diff --git a/docs/content/b2.md b/docs/content/b2.md
index c2a8d2e4d..4c49ac45c 100644
--- a/docs/content/b2.md
+++ b/docs/content/b2.md
@@ -96,9 +96,9 @@ This remote supports `--fast-list` which allows you to use fewer
 transactions in exchange for more memory. See the [rclone
 docs](/docs/#fast-list) for more details.
 
-### Modified time
+### Modification times
 
-The modified time is stored as metadata on the object as
+The modification time is stored as metadata on the object as
 `X-Bz-Info-src_last_modified_millis` as milliseconds since 1970-01-01
 in the Backblaze standard.  Other tools should be able to use this as
 a modified time.
diff --git a/docs/content/bisync.md b/docs/content/bisync.md
index e50d8cda2..be03f8cb8 100644
--- a/docs/content/bisync.md
+++ b/docs/content/bisync.md
@@ -298,7 +298,7 @@ while `--ignore-checksum` controls whether checksums are considered during the c
 if there ARE diffs.
 * Unless `--ignore-listing-checksum` is passed, bisync currently computes hashes for one path 
 *even when there's no common hash with the other path* 
-(for example, a [crypt](/crypt/#modified-time-and-hashes) remote.)
+(for example, a [crypt](/crypt/#modification-times-and-hashes) remote.)
 * If both paths support checksums and have a common hash, 
 AND `--ignore-listing-checksum` was not specified when creating the listings, 
 `--check-sync=only` can be used to compare Path1 vs. Path2 checksums (as of the time the previous listings were created.) 
@@ -402,7 +402,7 @@ Alternately, a `--resync` may be used (Path1 versions will be pushed
 to Path2). Consider the situation carefully and perhaps use `--dry-run`
 before you commit to the changes.
 
-### Modification time
+### Modification times
 
 Bisync relies on file timestamps to identify changed files and will
 _refuse_ to operate if backend lacks the modification time support.
diff --git a/docs/content/box.md b/docs/content/box.md
index 025e12902..576db2b03 100644
--- a/docs/content/box.md
+++ b/docs/content/box.md
@@ -199,7 +199,7 @@ d) Delete this remote
 y/e/d> y
 ```
 
-### Modified time and hashes
+### Modification times and hashes
 
 Box allows modification times to be set on objects accurate to 1
 second.  These will be used to detect whether objects need syncing or
diff --git a/docs/content/chunker.md b/docs/content/chunker.md
index 1ee168071..e7011dc08 100644
--- a/docs/content/chunker.md
+++ b/docs/content/chunker.md
@@ -244,7 +244,7 @@ revert (sometimes silently) to time/size comparison if compatible hashsums
 between source and target are not found.
 
 
-### Modified time
+### Modification times
 
 Chunker stores modification times using the wrapped remote so support
 depends on that. For a small non-chunked file the chunker overlay simply
diff --git a/docs/content/crypt.md b/docs/content/crypt.md
index 9599dccc1..269e26fed 100644
--- a/docs/content/crypt.md
+++ b/docs/content/crypt.md
@@ -405,7 +405,7 @@ Example:
 `1/12/qgm4avr35m5loi1th53ato71v0`
 
 
-### Modified time and hashes
+### Modification times and hashes
 
 Crypt stores modification times using the underlying remote so support
 depends on that.
diff --git a/docs/content/docs.md b/docs/content/docs.md
index d297e817e..30b2a5fbb 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -54,10 +54,12 @@ See the following for detailed instructions for
   * [Internet Archive](/internetarchive/)
   * [Jottacloud](/jottacloud/)
   * [Koofr](/koofr/)
+  * [Linkbox](/linkbox/)
   * [Mail.ru Cloud](/mailru/)
   * [Mega](/mega/)
   * [Memory](/memory/)
   * [Microsoft Azure Blob Storage](/azureblob/)
+  * [Microsoft Azure Files Storage](/azurefiles/)
   * [Microsoft OneDrive](/onedrive/)
   * [OpenStack Swift / Rackspace Cloudfiles / Blomp Cloud Storage / Memset Memstore](/swift/)
   * [OpenDrive](/opendrive/)
@@ -473,6 +475,10 @@ Note that arbitrary metadata may be added to objects using the
 `--metadata-set key=value` flag when the object is first uploaded.
 This flag can be repeated as many times as necessary.
 
+The [--metadata-mapper](#metadata-mapper) flag can be used to pass the
+name of a program in which can transform metadata when it is being
+copied from source to destination.
+
 ### Types of metadata
 
 Metadata is divided into two type. System metadata and User metadata.
@@ -1502,12 +1508,123 @@ from reaching the limit. Only applicable for `--max-transfer`
 
 Setting this flag enables rclone to copy the metadata from the source
 to the destination. For local backends this is ownership, permissions,
-xattr etc. See the [#metadata](metadata section) for more info.
+xattr etc. See the [metadata section](#metadata) for more info.
+
+### --metadata-mapper SpaceSepList {#metadata-mapper}
+
+If you supply the parameter `--metadata-mapper /path/to/program` then
+rclone will use that program to map metadata from source object to
+destination object.
+
+The argument to this flag should be a command with an optional space separated
+list of arguments. If one of the arguments has a space in then enclose
+it in `"`, if you want a literal `"` in an argument then enclose the
+argument in `"` and double the `"`. See [CSV encoding](https://godoc.org/encoding/csv)
+for more info.
+
+    --metadata-mapper "python bin/test_metadata_mapper.py"
+    --metadata-mapper 'python bin/test_metadata_mapper.py "argument with a space"'
+    --metadata-mapper 'python bin/test_metadata_mapper.py "argument with ""two"" quotes"'
+
+This uses a simple JSON based protocol with input on STDIN and output
+on STDOUT. This will be called for every file and directory copied and
+may be called concurrently.
+
+The program's job is to take a metadata blob on the input and turn it
+into a metadata blob on the output suitable for the destination
+backend.
+
+Input to the program (via STDIN) might look like this. This provides
+some context for the `Metadata` which may be important.
+
+- `SrcFs` is the config string for the remote that the object is currently on.
+- `SrcFsType` is the name of the source backend.
+- `DstFs` is the config string for the remote that the object is being copied to
+- `DstFsType` is the name of the destination backend.
+- `Remote` is the path of the file relative to the root.
+- `Size`, `MimeType`, `ModTime` are attributes of the file.
+- `IsDir` is `true` if this is a directory (not yet implemented).
+- `ID` is the source `ID` of the file if known.
+- `Metadata` is the backend specific metadata as described in the backend docs.
+
+```json
+{
+    "SrcFs": "gdrive:",
+    "SrcFsType": "drive",
+    "DstFs": "newdrive:user",
+    "DstFsType": "onedrive",
+    "Remote": "test.txt",
+    "Size": 6,
+    "MimeType": "text/plain; charset=utf-8",
+    "ModTime": "2022-10-11T17:53:10.286745272+01:00",
+    "IsDir": false,
+    "ID": "xyz",
+    "Metadata": {
+        "btime": "2022-10-11T16:53:11Z",
+        "content-type": "text/plain; charset=utf-8",
+        "mtime": "2022-10-11T17:53:10.286745272+01:00",
+        "owner": "user1@domain1.com",
+        "permissions": "...",
+        "description": "my nice file",
+        "starred": "false"
+    }
+}
+```
+
+The program should then modify the input as desired and send it to
+STDOUT. The returned `Metadata` field will be used in its entirety for
+the destination object. Any other fields will be ignored. Note in this
+example we translate user names and permissions and add something to
+the description:
+
+```json
+{
+    "Metadata": {
+        "btime": "2022-10-11T16:53:11Z",
+        "content-type": "text/plain; charset=utf-8",
+        "mtime": "2022-10-11T17:53:10.286745272+01:00",
+        "owner": "user1@domain2.com",
+        "permissions": "...",
+        "description": "my nice file [migrated from domain1]",
+        "starred": "false"
+    }
+}
+```
+
+Metadata can be removed here too.
+
+An example python program might look something like this to implement
+the above transformations.
+
+```python
+import sys, json
+
+i = json.load(sys.stdin)
+metadata = i["Metadata"]
+# Add tag to description
+if "description" in metadata:
+    metadata["description"] += " [migrated from domain1]"
+else:
+    metadata["description"] = "[migrated from domain1]"
+# Modify owner
+if "owner" in metadata:
+    metadata["owner"] = metadata["owner"].replace("domain1.com", "domain2.com")
+o = { "Metadata": metadata }
+json.dump(o, sys.stdout, indent="\t")
+```
+
+You can find this example (slightly expanded) in the rclone source code at
+[bin/test_metadata_mapper.py](https://github.com/rclone/rclone/blob/master/test_metadata_mapper.py).
+
+If you want to see the input to the metadata mapper and the output
+returned from it in the log you can use `-vv --dump mapper`.
+
+See the [metadata section](#metadata) for more info.
 
 ### --metadata-set key=value
 
 Add metadata `key` = `value` when uploading. This can be repeated as
-many times as required. See the [#metadata](metadata section) for more
+many times as required. See the [metadata section](#metadata) for more
 info.
 
 ### --modify-window=TIME ###
@@ -1750,9 +1867,9 @@ for more info.
 
 Eg
 
-    --password-command echo hello
-    --password-command echo "hello with space"
-    --password-command echo "hello with ""quotes"" and space"
+    --password-command "echo hello"
+    --password-command 'echo "hello with space"'
+    --password-command 'echo "hello with ""quotes"" and space"'
 
 See the [Configuration Encryption](#configuration-encryption) for more info.
 
@@ -2501,6 +2618,12 @@ This dumps a list of the open files at the end of the command.  It
 uses the `lsof` command to do that so you'll need that installed to
 use it.
 
+#### --dump mapper ####
+
+This shows the JSON blobs being sent to the program supplied with
+`--metadata-mapper` and received from it. It can be useful for
+debugging the metadata mapper interface.
+
 ### --memprofile=FILE ###
 
 Write memory profile to file. This can be analysed with `go tool pprof`.
diff --git a/docs/content/drive.md b/docs/content/drive.md
index a4c6a2a4d..246430b98 100644
--- a/docs/content/drive.md
+++ b/docs/content/drive.md
@@ -361,10 +361,14 @@ large folder (10600 directories, 39000 files):
 - without `--fast-list`: 22:05 min
 - with `--fast-list`: 58s
 
-### Modified time
+### Modification times and hashes
 
 Google drive stores modification times accurate to 1 ms.
 
+Hash algorithms MD5, SHA1 and SHA256 are supported. Note, however,
+that a small fraction of files uploaded may not have SHA1 or SHA256
+hashes especially if they were uploaded before 2018.
+
 ### Restricted filename characters
 
 Only Invalid UTF-8 bytes will be [replaced](/overview/#invalid-utf8),
@@ -1528,9 +1532,10 @@ Waiting a moderate period of time between attempts (estimated to be
 approximately 1 hour) and/or not using --fast-list both seem to be
 effective in preventing the problem.
 
-### Hashes
+### SHA1 or SHA256 hashes may be missing
 
-We need to say that all files have MD5 hashes, but a small fraction of files uploaded may not have SHA1 or SHA256 hashes especially if they were uploaded before 2018.
+All files have MD5 hashes, but a small fraction of files uploaded may
+not have SHA1 or SHA256 hashes especially if they were uploaded before 2018.
 
 ## Making your own client_id
 
diff --git a/docs/content/dropbox.md b/docs/content/dropbox.md
index 609393425..fa20cfe19 100644
--- a/docs/content/dropbox.md
+++ b/docs/content/dropbox.md
@@ -97,7 +97,7 @@ You can then use team folders like this `remote:/TeamFolder` and
 A leading `/` for a Dropbox personal account will do nothing, but it
 will take an extra HTTP transaction so it should be avoided.
 
-### Modified time and Hashes
+### Modification times and hashes
 
 Dropbox supports modified times, but the only way to set a
 modification time is to re-upload the file.
diff --git a/docs/content/fichier.md b/docs/content/fichier.md
index a86d72bbb..8576cb9aa 100644
--- a/docs/content/fichier.md
+++ b/docs/content/fichier.md
@@ -76,11 +76,11 @@ To copy a local directory to a 1Fichier directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and hashes ###
+### Modification times and hashes
 
 1Fichier does not support modification times. It supports the Whirlpool hash algorithm.
 
-### Duplicated files ###
+### Duplicated files
 
 1Fichier can have two files with exactly the same name and path (unlike a
 normal file system).
diff --git a/docs/content/filefabric.md b/docs/content/filefabric.md
index 69ee67fcd..61f2ae815 100644
--- a/docs/content/filefabric.md
+++ b/docs/content/filefabric.md
@@ -101,7 +101,7 @@ To copy a local directory to an Enterprise File Fabric directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and hashes
+### Modification times and hashes
 
 The Enterprise File Fabric allows modification times to be set on
 files accurate to 1 second.  These will be used to detect whether
diff --git a/docs/content/ftp.md b/docs/content/ftp.md
index dea99f0c3..7d01d9f8e 100644
--- a/docs/content/ftp.md
+++ b/docs/content/ftp.md
@@ -486,7 +486,7 @@ at present.
 
 The `ftp_proxy` environment variable is not currently supported.
 
-#### Modified time
+### Modification times
 
 File modification time (timestamps) is supported to 1 second resolution
 for major FTP servers: ProFTPd, PureFTPd, VsFTPd, and FileZilla FTP server.
diff --git a/docs/content/googlecloudstorage.md b/docs/content/googlecloudstorage.md
index 80e5b75b8..0c7512964 100644
--- a/docs/content/googlecloudstorage.md
+++ b/docs/content/googlecloudstorage.md
@@ -247,7 +247,7 @@ Eg `--header-upload "Content-Type text/potato"`
 Note that the last of these is for setting custom metadata in the form
 `--header-upload "x-goog-meta-key: value"`
 
-### Modification time
+### Modification times
 
 Google Cloud Storage stores md5sum natively.
 Google's [gsutil](https://cloud.google.com/storage/docs/gsutil) tool stores modification time
diff --git a/docs/content/googlephotos.md b/docs/content/googlephotos.md
index e10b2a9fe..bce5a26f7 100644
--- a/docs/content/googlephotos.md
+++ b/docs/content/googlephotos.md
@@ -428,7 +428,7 @@ if you uploaded an image to `upload` then uploaded the same image to
 what it was uploaded with initially, not what you uploaded it with to
 `album`.  In practise this shouldn't cause too many problems.
 
-### Modified time
+### Modification times
 
 The date shown of media in Google Photos is the creation date as
 determined by the EXIF information, or the upload date if that is not
diff --git a/docs/content/hdfs.md b/docs/content/hdfs.md
index 6d22e446b..58889c65f 100644
--- a/docs/content/hdfs.md
+++ b/docs/content/hdfs.md
@@ -126,7 +126,7 @@ username = root
 You can stop this image with `docker kill rclone-hdfs` (**NB** it does not use volumes, so all data
 uploaded will be lost.)
 
-### Modified time
+### Modification times
 
 Time accurate to 1 second is stored.
 
diff --git a/docs/content/hidrive.md b/docs/content/hidrive.md
index 91e8ab29b..830778119 100644
--- a/docs/content/hidrive.md
+++ b/docs/content/hidrive.md
@@ -123,7 +123,7 @@ Using
 
 the process is very similar to the process of initial setup exemplified before.
 
-### Modified time and hashes
+### Modification times and hashes
 
 HiDrive allows modification times to be set on objects accurate to 1 second.
 
diff --git a/docs/content/http.md b/docs/content/http.md
index 06ae9cada..19667dda4 100644
--- a/docs/content/http.md
+++ b/docs/content/http.md
@@ -105,7 +105,7 @@ Sync the remote `directory` to `/home/local/directory`, deleting any excess file
 
 This remote is read only - you can't upload files to an HTTP server.
 
-### Modified time
+### Modification times
 
 Most HTTP servers store time accurate to 1 second.
 
diff --git a/docs/content/install.sh b/docs/content/install.sh
index 0adb74400..302882d82 100755
--- a/docs/content/install.sh
+++ b/docs/content/install.sh
@@ -193,6 +193,8 @@ case "$OS" in
     exit 2
 esac
 
+#cleanup
+rm -rf "$tmp_dir"
 
 #update version variable post install
 version=$(rclone --version 2>>errors | head -n 1)
diff --git a/docs/content/jottacloud.md b/docs/content/jottacloud.md
index d2b19e53e..ef570c394 100644
--- a/docs/content/jottacloud.md
+++ b/docs/content/jottacloud.md
@@ -245,7 +245,7 @@ Note also that with rclone version 1.58 and newer, information about
 [MIME types](/overview/#mime-type) and metadata item [utime](#metadata)
 are not available when using `--fast-list`.
 
-### Modified time and hashes
+### Modification times and hashes
 
 Jottacloud allows modification times to be set on objects accurate to 1
 second. These will be used to detect whether objects need syncing or
diff --git a/docs/content/linkbox.md b/docs/content/linkbox.md
new file mode 100644
index 000000000..4ea172b5e
--- /dev/null
+++ b/docs/content/linkbox.md
@@ -0,0 +1,76 @@
+---
+title: "Linkbox"
+description: "Rclone docs for Linkbox"
+versionIntroduced: "v1.65"
+---
+
+# {{< icon "fa fa-infinity" >}} Linkbox
+
+Linkbox is [a private cloud drive](https://linkbox.to/).
+
+## Configuration
+
+Here is an example of making a remote for Linkbox.
+
+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> remote
+
+Option Storage.
+Type of storage to configure.
+Choose a number from below, or type in your own value.
+XX / Linkbox
+   \ (linkbox)
+Storage> XX
+
+Option token.
+Token from https://www.linkbox.to/admin/account
+Enter a value.
+token> testFromCLToken
+
+Configuration complete.
+Options:
+- type: linkbox
+- token: XXXXXXXXXXX
+Keep this "linkbox" remote?
+y) Yes this is OK (default)
+e) Edit this remote
+d) Delete this remote
+y/e/d> y
+
+```
+
+{{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/linkbox/linkbox.go then run make backenddocs" >}}
+### Standard options
+
+Here are the Standard options specific to linkbox (Linkbox).
+
+#### --linkbox-token
+
+Token from https://www.linkbox.to/admin/account
+
+Properties:
+
+- Config:      token
+- Env Var:     RCLONE_LINKBOX_TOKEN
+- Type:        string
+- Required:    true
+
+{{< rem autogenerated options stop >}}
+
+## Limitations
+
+Invalid UTF-8 bytes will also be [replaced](https://rclone.org/overview/#invalid-utf8),
+as they can't be used in JSON strings.
diff --git a/docs/content/local.md b/docs/content/local.md
index 42327a940..d7881eef6 100644
--- a/docs/content/local.md
+++ b/docs/content/local.md
@@ -19,10 +19,10 @@ For consistencies sake one can also configure a remote of type
 rclone remote paths, e.g. `remote:path/to/wherever`, but it is probably
 easier not to.
 
-### Modified time ###
+### Modification times
 
-Rclone reads and writes the modified time using an accuracy determined by
-the OS. Typically this is 1ns on Linux, 10 ns on Windows and 1 Second
+Rclone reads and writes the modification times using an accuracy determined
+by the OS. Typically this is 1ns on Linux, 10 ns on Windows and 1 Second
 on OS X.
 
 ### Filenames ###
diff --git a/docs/content/mailru.md b/docs/content/mailru.md
index 57d3dbbde..9aae4013d 100644
--- a/docs/content/mailru.md
+++ b/docs/content/mailru.md
@@ -123,17 +123,15 @@ excess files in the path.
 
     rclone sync --interactive /home/local/directory remote:directory
 
-### Modified time
+### Modification times and hashes
 
 Files support a modification time attribute with up to 1 second precision.
 Directories do not have a modification time, which is shown as "Jan 1 1970".
 
-### Hash checksums
-
-Hash sums use a custom Mail.ru algorithm based on SHA1.
+File hashes are supported, with a custom Mail.ru algorithm based on SHA1.
 If file size is less than or equal to the SHA1 block size (20 bytes),
 its hash is simply its data right-padded with zero bytes.
-Hash sum of a larger file is computed as a SHA1 sum of the file data
+Hashes of a larger file is computed as a SHA1 of the file data
 bytes concatenated with a decimal representation of the data length.
 
 ### Emptying Trash
diff --git a/docs/content/mega.md b/docs/content/mega.md
index 5532a4fbb..53a275868 100644
--- a/docs/content/mega.md
+++ b/docs/content/mega.md
@@ -82,7 +82,7 @@ To copy a local directory to an Mega directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and hashes
+### Modification times and hashes
 
 Mega does not support modification times or hashes yet.
 
diff --git a/docs/content/memory.md b/docs/content/memory.md
index 843fc3cbd..783fc2815 100644
--- a/docs/content/memory.md
+++ b/docs/content/memory.md
@@ -54,7 +54,7 @@ testing or with an rclone server or rclone mount, e.g.
     rclone serve webdav :memory:
     rclone serve sftp :memory:
 
-### Modified time and hashes
+### Modification times and hashes
 
 The memory backend supports MD5 hashes and modification times accurate to 1 nS.
 
diff --git a/docs/content/onedrive.md b/docs/content/onedrive.md
index cf7ca5175..da83f448a 100644
--- a/docs/content/onedrive.md
+++ b/docs/content/onedrive.md
@@ -162,7 +162,7 @@ You may try to [verify you account](https://docs.microsoft.com/en-us/azure/activ
 Note: If you have a special region, you may need a different host in step 4 and 5. Here are [some hints](https://github.com/rclone/rclone/blob/bc23bf11db1c78c6ebbf8ea538fbebf7058b4176/backend/onedrive/onedrive.go#L86).
 
 
-### Modification time and hashes
+### Modification times and hashes
 
 OneDrive allows modification times to be set on objects accurate to 1
 second.  These will be used to detect whether objects need syncing or
diff --git a/docs/content/opendrive.md b/docs/content/opendrive.md
index 0758470a6..90d5f4cf4 100644
--- a/docs/content/opendrive.md
+++ b/docs/content/opendrive.md
@@ -64,12 +64,14 @@ To copy a local directory to an OpenDrive directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and MD5SUMs
+### Modification times and hashes
 
 OpenDrive allows modification times to be set on objects accurate to 1
 second. These will be used to detect whether objects need syncing or
 not.
 
+The MD5 hash algorithm is supported.
+
 ### Restricted filename characters
 
 | Character | Value | Replacement |
diff --git a/docs/content/oracleobjectstorage.md b/docs/content/oracleobjectstorage.md
index 48b0b4f17..7ed7a0870 100644
--- a/docs/content/oracleobjectstorage.md
+++ b/docs/content/oracleobjectstorage.md
@@ -154,6 +154,7 @@ Rclone supports the following OCI authentication provider.
     No authentication
 
 ### User Principal
+
 Sample rclone config file for Authentication Provider User Principal:
 
     [oos]
@@ -174,6 +175,7 @@ Considerations:
 - If the user is deleted, the config file will no longer work and may cause automation regressions that use the user's credentials.
 
 ###  Instance Principal
+
 An OCI compute instance can be authorized to use rclone by using it's identity and certificates as an instance principal. 
 With this approach no credentials have to be stored and managed.
 
@@ -203,6 +205,7 @@ Considerations:
 - It is applicable for oci compute instances only. It cannot be used on external instance or resources.
 
 ### Resource Principal
+
 Resource principal auth is very similar to instance principal auth but used for resources that are not 
 compute instances such as [serverless functions](https://docs.oracle.com/en-us/iaas/Content/Functions/Concepts/functionsoverview.htm). 
 To use resource principal ensure Rclone process is started with these environment variables set in its process.
@@ -222,6 +225,7 @@ Sample rclone configuration file for Authentication Provider Resource Principal:
     provider = resource_principal_auth
 
 ### No authentication
+
 Public buckets do not require any authentication mechanism to read objects.
 Sample rclone configuration file for No authentication:
     
@@ -232,10 +236,9 @@ Sample rclone configuration file for No authentication:
     region = us-ashburn-1
     provider = no_auth
 
-## Options
-### Modified time
+### Modification times and hashes
 
-The modified time is stored as metadata on the object as
+The modification time is stored as metadata on the object as
 `opc-meta-mtime` as floating point since the epoch, accurate to 1 ns.
 
 If the modification time needs to be updated rclone will attempt to perform a server
@@ -245,6 +248,8 @@ In the case the object is larger than 5Gb, the object will be uploaded rather th
 Note that reading this from the object takes an additional `HEAD` request as the metadata
 isn't returned in object listings.
 
+The MD5 hash algorithm is supported.
+
 ### Multipart uploads
 
 rclone supports multipart uploads with OOS which means that it can
diff --git a/docs/content/overview.md b/docs/content/overview.md
index a49c6bd4b..e95411e18 100644
--- a/docs/content/overview.md
+++ b/docs/content/overview.md
@@ -35,10 +35,12 @@ Here is an overview of the major features of each cloud storage system.
 | Internet Archive             | MD5, SHA1, CRC32  | R/W ¹¹  | No               | No              | -         | RWU      |
 | Jottacloud                   | MD5               | R/W     | Yes              | No              | R         | RW       |
 | Koofr                        | MD5               | -       | Yes              | No              | -         | -        |
+| Linkbox                      | -                 | R       | No               | No              | -         | -        |
 | Mail.ru Cloud                | Mailru ⁶          | R/W     | Yes              | No              | -         | -        |
 | Mega                         | -                 | -       | No               | Yes             | -         | -        |
 | Memory                       | MD5               | R/W     | No               | No              | -         | -        |
 | Microsoft Azure Blob Storage | MD5               | R/W     | No               | No              | R/W       | -        |
+| Microsoft Azure Files Storage | MD5              | R/W     | Yes              | No              | R/W       | -        |
 | Microsoft OneDrive           | QuickXorHash ⁵    | R/W     | Yes              | No              | R         | -        |
 | OpenDrive                    | MD5               | R/W     | Yes              | Partial ⁸       | -         | -        |
 | OpenStack Swift              | MD5               | R/W     | No               | No              | R/W       | -        |
@@ -88,7 +90,7 @@ mistake or an unsupported feature.
 ⁹ QingStor does not support SetModTime for objects bigger than 5 GiB.
 
 ¹⁰ FTP supports modtimes for the major FTP servers, and also others
-if they advertised required protocol extensions. See [this](/ftp/#modified-time)
+if they advertised required protocol extensions. See [this](/ftp/#modification-times)
 for more details.
 
 ¹¹ Internet Archive requires option `wait_archive` to be set to a non-zero value
@@ -490,6 +492,7 @@ upon backend-specific capabilities.
 | Mega                         | Yes   | No   | Yes  | Yes     | Yes     | No    | No           | No                | Yes          | Yes   | Yes      |
 | Memory                       | No    | Yes  | No   | No      | No      | Yes   | Yes          | No                | No           | No    | No       |
 | Microsoft Azure Blob Storage | Yes   | Yes  | No   | No      | No      | Yes   | Yes          | Yes               | No           | No    | No       |
+| Microsoft Azure Files Storage | No   | Yes  | Yes  | Yes     | No      | No    | Yes          | Yes               | No           | Yes   | Yes      |
 | Microsoft OneDrive           | Yes   | Yes  | Yes  | Yes     | Yes     | Yes   | No           | No                | Yes          | Yes   | Yes      |
 | OpenDrive                    | Yes   | Yes  | Yes  | Yes     | No      | No    | No           | No                | No           | No    | Yes      |
 | OpenStack Swift              | Yes ¹ | Yes  | No   | No      | No      | Yes   | Yes          | No                | No           | Yes   | No       |
diff --git a/docs/content/pcloud.md b/docs/content/pcloud.md
index bbf06a141..3a28668f5 100644
--- a/docs/content/pcloud.md
+++ b/docs/content/pcloud.md
@@ -86,7 +86,7 @@ To copy a local directory to a pCloud directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and hashes ###
+### Modification times and hashes
 
 pCloud allows modification times to be set on objects accurate to 1
 second.  These will be used to detect whether objects need syncing or
diff --git a/docs/content/pikpak.md b/docs/content/pikpak.md
index f20ba3c9e..502219238 100644
--- a/docs/content/pikpak.md
+++ b/docs/content/pikpak.md
@@ -71,6 +71,13 @@ d) Delete this remote
 y/e/d> y
 ```
 
+### Modification times and hashes
+
+PikPak keeps modification times on objects, and updates them when uploading objects,
+but it does not support changing only the modification time
+
+The MD5 hash algorithm is supported.
+
 {{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/pikpak/pikpak.go then run make backenddocs" >}}
 ### Standard options
 
@@ -294,12 +301,13 @@ Result:
 
 {{< rem autogenerated options stop >}}
 
-## Limitations ##
+## Limitations
 
-### Hashes ###
+### Hashes may be empty
 
 PikPak supports MD5 hash, but sometimes given empty especially for user-uploaded files.
 
-### Deleted files ###
+### Deleted files still visible with trashed-only
 
-Deleted files will still be visible with `--pikpak-trashed-only` even after the trash emptied. This goes away after few days.
+Deleted files will still be visible with `--pikpak-trashed-only` even after the
+trash emptied. This goes away after few days.
diff --git a/docs/content/premiumizeme.md b/docs/content/premiumizeme.md
index 9324b3e8d..6b8ccace4 100644
--- a/docs/content/premiumizeme.md
+++ b/docs/content/premiumizeme.md
@@ -84,7 +84,7 @@ To copy a local directory to an premiumize.me directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and hashes
+### Modification times and hashes
 
 premiumize.me does not support modification times or hashes, therefore
 syncing will default to `--size-only` checking.  Note that using
diff --git a/docs/content/protondrive.md b/docs/content/protondrive.md
index b3bb9f7aa..5e46803c4 100644
--- a/docs/content/protondrive.md
+++ b/docs/content/protondrive.md
@@ -95,10 +95,12 @@ To copy a local directory to an Proton Drive directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time
+### Modification times and hashes
 
 Proton Drive Bridge does not support updating modification times yet.
 
+The SHA1 hash algorithm is supported.
+
 ### Restricted filename characters
 
 Invalid UTF-8 bytes will be [replaced](/overview/#invalid-utf8), also left and 
diff --git a/docs/content/quatrix.md b/docs/content/quatrix.md
index 66819c057..02ea14cea 100644
--- a/docs/content/quatrix.md
+++ b/docs/content/quatrix.md
@@ -121,7 +121,7 @@ d) Delete this remote
 y/e/d> y
 ```
 
-### Modified time and hashes
+### Modification times and hashes
 
 Quatrix allows modification times to be set on objects accurate to 1 microsecond.
 These will be used to detect whether objects need syncing or not.
diff --git a/docs/content/s3.md b/docs/content/s3.md
index c4cc881fa..b1853b3e6 100644
--- a/docs/content/s3.md
+++ b/docs/content/s3.md
@@ -271,7 +271,9 @@ d) Delete this remote
 y/e/d>
 ```
 
-### Modified time
+### Modification times and hashes
+
+#### Modification times
 
 The modified time is stored as metadata on the object as
 `X-Amz-Meta-Mtime` as floating point since the epoch, accurate to 1 ns.
@@ -284,6 +286,29 @@ storage the object will be uploaded rather than copied.
 Note that reading this from the object takes an additional `HEAD`
 request as the metadata isn't returned in object listings.
 
+#### Hashes
+
+For small objects which weren't uploaded as multipart uploads (objects
+sized below `--s3-upload-cutoff` if uploaded with rclone) rclone uses
+the `ETag:` header as an MD5 checksum.
+
+However for objects which were uploaded as multipart uploads or with
+server side encryption (SSE-AWS or SSE-C) the `ETag` header is no
+longer the MD5 sum of the data, so rclone adds an additional piece of
+metadata `X-Amz-Meta-Md5chksum` which is a base64 encoded MD5 hash (in
+the same format as is required for `Content-MD5`).  You can use base64 -d and hexdump to check this value manually:
+
+    echo 'VWTGdNx3LyXQDfA0e2Edxw==' | base64 -d | hexdump
+
+or you can use `rclone check` to verify the hashes are OK.
+
+For large objects, calculating this hash can take some time so the
+addition of this hash can be disabled with `--s3-disable-checksum`.
+This will mean that these objects do not have an MD5 checksum.
+
+Note that reading this from the object takes an additional `HEAD`
+request as the metadata isn't returned in object listings.
+
 ### Reducing costs
 
 #### Avoiding HEAD requests to read the modification time
@@ -375,29 +400,6 @@ there for more details.
 
 Setting this flag increases the chance for undetected upload failures.
 
-### Hashes
-
-For small objects which weren't uploaded as multipart uploads (objects
-sized below `--s3-upload-cutoff` if uploaded with rclone) rclone uses
-the `ETag:` header as an MD5 checksum.
-
-However for objects which were uploaded as multipart uploads or with
-server side encryption (SSE-AWS or SSE-C) the `ETag` header is no
-longer the MD5 sum of the data, so rclone adds an additional piece of
-metadata `X-Amz-Meta-Md5chksum` which is a base64 encoded MD5 hash (in
-the same format as is required for `Content-MD5`).  You can use base64 -d and hexdump to check this value manually:
-
-    echo 'VWTGdNx3LyXQDfA0e2Edxw==' | base64 -d | hexdump
-
-or you can use `rclone check` to verify the hashes are OK.
-
-For large objects, calculating this hash can take some time so the
-addition of this hash can be disabled with `--s3-disable-checksum`.
-This will mean that these objects do not have an MD5 checksum.
-
-Note that reading this from the object takes an additional `HEAD`
-request as the metadata isn't returned in object listings.
-
 ### Versions
 
 When bucket versioning is enabled (this can be done with rclone with
@@ -660,7 +662,8 @@ According to AWS's [documentation on S3 Object Lock](https://docs.aws.amazon.com
 
 > If you configure a default retention period on a bucket, requests to upload objects in such a bucket must include the Content-MD5 header.
 
-As mentioned in the [Hashes](#hashes) section, small files that are not uploaded as multipart, use a different tag, causing the upload to fail.
+As mentioned in the [Modification times and hashes](#modification-times-and-hashes) section,
+small files that are not uploaded as multipart, use a different tag, causing the upload to fail.
 A simple solution is to set the `--s3-upload-cutoff 0` and force all the files to be uploaded as multipart.
 
 {{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/s3/s3.go then run make backenddocs" >}}
diff --git a/docs/content/sftp.md b/docs/content/sftp.md
index f0d4a5651..e2cd91b83 100644
--- a/docs/content/sftp.md
+++ b/docs/content/sftp.md
@@ -359,7 +359,7 @@ commands is prohibited.  Set the configuration option `disable_hashcheck`
 to `true` to disable checksumming entirely, or set `shell_type` to `none`
 to disable all functionality based on remote shell command execution.
 
-### Modified time
+### Modification times and hashes
 
 Modified times are stored on the server to 1 second precision.
 
diff --git a/docs/content/sharefile.md b/docs/content/sharefile.md
index a362f1dcc..60d518687 100644
--- a/docs/content/sharefile.md
+++ b/docs/content/sharefile.md
@@ -105,7 +105,7 @@ To copy a local directory to an ShareFile directory called backup
 
 Paths may be as deep as required, e.g. `remote:directory/subdirectory`.
 
-### Modified time and hashes
+### Modification times and hashes
 
 ShareFile allows modification times to be set on objects accurate to 1
 second.  These will be used to detect whether objects need syncing or
diff --git a/docs/content/sugarsync.md b/docs/content/sugarsync.md
index 0d9a29d73..30633051c 100644
--- a/docs/content/sugarsync.md
+++ b/docs/content/sugarsync.md
@@ -98,7 +98,7 @@ Paths may be as deep as required, e.g. `remote:directory/subdirectory`.
 create a folder, which rclone will create as a "Sync Folder" with
 SugarSync.
 
-### Modified time and hashes
+### Modification times and hashes
 
 SugarSync does not support modification times or hashes, therefore
 syncing will default to `--size-only` checking.  Note that using
diff --git a/docs/content/swift.md b/docs/content/swift.md
index 4b4817f4a..ddcfa45f7 100644
--- a/docs/content/swift.md
+++ b/docs/content/swift.md
@@ -227,7 +227,7 @@ sufficient to determine if it is "dirty". By using `--update` along with
 `--use-server-modtime`, you can avoid the extra API call and simply upload
 files whose local modtime is newer than the time it was last uploaded.
 
-### Modified time
+### Modification times and hashes
 
 The modified time is stored as metadata on the object as
 `X-Object-Meta-Mtime` as floating point since the epoch accurate to 1
@@ -236,6 +236,8 @@ ns.
 This is a de facto standard (used in the official python-swiftclient
 amongst others) for storing the modification time for an object.
 
+The MD5 hash algorithm is supported.
+
 ### Restricted filename characters
 
 | Character | Value | Replacement |
diff --git a/docs/content/uptobox.md b/docs/content/uptobox.md
index ed717d6c2..9a08f3f53 100644
--- a/docs/content/uptobox.md
+++ b/docs/content/uptobox.md
@@ -82,7 +82,7 @@ To copy a local directory to an Uptobox directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and hashes
+### Modification times and hashes
 
 Uptobox supports neither modified times nor checksums. All timestamps
 will read as that set by `--default-time`.
diff --git a/docs/content/webdav.md b/docs/content/webdav.md
index bbbb20527..6f246a017 100644
--- a/docs/content/webdav.md
+++ b/docs/content/webdav.md
@@ -101,7 +101,7 @@ To copy a local directory to an WebDAV directory called backup
 
     rclone copy /home/source remote:backup
 
-### Modified time and hashes ###
+### Modification times and hashes
 
 Plain WebDAV does not support modified times.  However when used with
 Fastmail Files, Owncloud or Nextcloud rclone will support modified times.
diff --git a/docs/content/yandex.md b/docs/content/yandex.md
index 0606fdfbd..d62b33e2f 100644
--- a/docs/content/yandex.md
+++ b/docs/content/yandex.md
@@ -87,14 +87,12 @@ excess files in the path.
 
 Yandex paths may be as deep as required, e.g. `remote:directory/subdirectory`.
 
-### Modified time
+### Modification times and hashes
 
 Modified times are supported and are stored accurate to 1 ns in custom
 metadata called `rclone_modified` in RFC3339 with nanoseconds format.
 
-### MD5 checksums
-
-MD5 checksums are natively supported by Yandex Disk.
+The MD5 hash algorithm is natively supported by Yandex Disk.
 
 ### Emptying Trash
 
diff --git a/docs/content/zoho.md b/docs/content/zoho.md
index a103c4613..c185a2271 100644
--- a/docs/content/zoho.md
+++ b/docs/content/zoho.md
@@ -107,13 +107,11 @@ excess files in the path.
 
 Zoho paths may be as deep as required, eg `remote:directory/subdirectory`.
 
-### Modified time
+### Modification times and hashes
 
 Modified times are currently not supported for Zoho Workdrive
 
-### Checksums
-
-No checksums are supported.
+No hash algorithms are supported.
 
 ### Usage information
 
diff --git a/docs/layouts/chrome/navbar.html b/docs/layouts/chrome/navbar.html
index 91cc10c3c..8b3d7f871 100644
--- a/docs/layouts/chrome/navbar.html
+++ b/docs/layouts/chrome/navbar.html
@@ -77,10 +77,12 @@
           <a class="dropdown-item" href="/internetarchive/"><i class="fa fa-archive fa-fw"></i> Internet Archive</a>
           <a class="dropdown-item" href="/jottacloud/"><i class="fa fa-cloud fa-fw"></i> Jottacloud</a>
           <a class="dropdown-item" href="/koofr/"><i class="fa fa-suitcase fa-fw"></i> Koofr</a>
+          <a class="dropdown-item" href="/linkbox/"><i class="fa fa-infinity"></i> Linkbox</a>
           <a class="dropdown-item" href="/mailru/"><i class="fa fa-at fa-fw"></i> Mail.ru Cloud</a>
           <a class="dropdown-item" href="/mega/"><i class="fa fa-archive fa-fw"></i> Mega</a>
           <a class="dropdown-item" href="/memory/"><i class="fas fa-memory fa-fw"></i> Memory</a>
           <a class="dropdown-item" href="/azureblob/"><i class="fab fa-windows fa-fw"></i> Microsoft Azure Blob Storage</a>
+          <a class="dropdown-item" href="/azurefiles/"><i class="fab fa-windows fa-fw"></i> Microsoft Azure Files Storage</a>
           <a class="dropdown-item" href="/onedrive/"><i class="fab fa-windows fa-fw"></i> Microsoft OneDrive</a>
           <a class="dropdown-item" href="/opendrive/"><i class="fa fa-space-shuttle fa-fw"></i> OpenDrive</a>
           <a class="dropdown-item" href="/qingstor/"><i class="fas fa-hdd fa-fw"></i> QingStor</a>
diff --git a/fs/config.go b/fs/config.go
index bef0f1693..d405f55bb 100644
--- a/fs/config.go
+++ b/fs/config.go
@@ -149,6 +149,7 @@ type ConfigInfo struct {
 	DefaultTime                Time // time that directories with no time should display
 	Inplace                    bool // Download directly to destination file instead of atomic download to temp/rename
 	PartialSuffix              string
+	MetadataMapper             SpaceSepList
 }
 
 // NewConfig creates a new config with everything set to the default
diff --git a/fs/config/configflags/configflags.go b/fs/config/configflags/configflags.go
index 4c9709c0e..7d856cf12 100644
--- a/fs/config/configflags/configflags.go
+++ b/fs/config/configflags/configflags.go
@@ -54,7 +54,7 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
 	flags.StringVarP(flagSet, &cacheDir, "cache-dir", "", config.GetCacheDir(), "Directory rclone will use for caching", "Config")
 	flags.StringVarP(flagSet, &tempDir, "temp-dir", "", os.TempDir(), "Directory rclone will use for temporary files", "Config")
 	flags.BoolVarP(flagSet, &ci.CheckSum, "checksum", "c", ci.CheckSum, "Check for changes with size & checksum (if available, or fallback to size only).", "Copy")
-	flags.BoolVarP(flagSet, &ci.SizeOnly, "size-only", "", ci.SizeOnly, "Skip based on size only, not mod-time or checksum", "Copy")
+	flags.BoolVarP(flagSet, &ci.SizeOnly, "size-only", "", ci.SizeOnly, "Skip based on size only, not modtime or checksum", "Copy")
 	flags.BoolVarP(flagSet, &ci.IgnoreTimes, "ignore-times", "I", ci.IgnoreTimes, "Don't skip files that match size and time - transfer all files", "Copy")
 	flags.BoolVarP(flagSet, &ci.IgnoreExisting, "ignore-existing", "", ci.IgnoreExisting, "Skip all files that exist on destination", "Copy")
 	flags.BoolVarP(flagSet, &ci.IgnoreErrors, "ignore-errors", "", ci.IgnoreErrors, "Delete even if there are I/O errors", "Sync")
@@ -80,14 +80,14 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
 	flags.BoolVarP(flagSet, &ci.UseServerModTime, "use-server-modtime", "", ci.UseServerModTime, "Use server modified time instead of object metadata", "Config")
 	flags.BoolVarP(flagSet, &ci.NoGzip, "no-gzip-encoding", "", ci.NoGzip, "Don't set Accept-Encoding: gzip", "Networking")
 	flags.IntVarP(flagSet, &ci.MaxDepth, "max-depth", "", ci.MaxDepth, "If set limits the recursion depth to this", "Filter")
-	flags.BoolVarP(flagSet, &ci.IgnoreSize, "ignore-size", "", false, "Ignore size when skipping use mod-time or checksum", "Copy")
+	flags.BoolVarP(flagSet, &ci.IgnoreSize, "ignore-size", "", false, "Ignore size when skipping use modtime or checksum", "Copy")
 	flags.BoolVarP(flagSet, &ci.IgnoreChecksum, "ignore-checksum", "", ci.IgnoreChecksum, "Skip post copy check of checksums", "Copy")
 	flags.BoolVarP(flagSet, &ci.IgnoreCaseSync, "ignore-case-sync", "", ci.IgnoreCaseSync, "Ignore case when synchronizing", "Copy")
 	flags.BoolVarP(flagSet, &ci.NoTraverse, "no-traverse", "", ci.NoTraverse, "Don't traverse destination file system on copy", "Copy")
 	flags.BoolVarP(flagSet, &ci.CheckFirst, "check-first", "", ci.CheckFirst, "Do all the checks before starting transfers", "Copy")
 	flags.BoolVarP(flagSet, &ci.NoCheckDest, "no-check-dest", "", ci.NoCheckDest, "Don't check the destination, copy regardless", "Copy")
 	flags.BoolVarP(flagSet, &ci.NoUnicodeNormalization, "no-unicode-normalization", "", ci.NoUnicodeNormalization, "Don't normalize unicode characters in filenames", "Config")
-	flags.BoolVarP(flagSet, &ci.NoUpdateModTime, "no-update-modtime", "", ci.NoUpdateModTime, "Don't update destination mod-time if files identical", "Copy")
+	flags.BoolVarP(flagSet, &ci.NoUpdateModTime, "no-update-modtime", "", ci.NoUpdateModTime, "Don't update destination modtime if files identical", "Copy")
 	flags.StringArrayVarP(flagSet, &ci.CompareDest, "compare-dest", "", nil, "Include additional comma separated server-side paths during comparison", "Copy")
 	flags.StringArrayVarP(flagSet, &ci.CopyDest, "copy-dest", "", nil, "Implies --compare-dest but also copies files from paths into destination", "Copy")
 	flags.StringVarP(flagSet, &ci.BackupDir, "backup-dir", "", ci.BackupDir, "Make backups into hierarchy based in DIR", "Sync")
@@ -150,6 +150,7 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
 	flags.FVarP(flagSet, &ci.DefaultTime, "default-time", "", "Time to show if modtime is unknown for files and directories", "Config,Listing")
 	flags.BoolVarP(flagSet, &ci.Inplace, "inplace", "", ci.Inplace, "Download directly to destination file instead of atomic download to temp/rename", "Copy")
 	flags.StringVarP(flagSet, &partialSuffix, "partial-suffix", "", ci.PartialSuffix, "Add partial-suffix to temporary file name when --inplace is not used", "Copy")
+	flags.FVarP(flagSet, &ci.MetadataMapper, "metadata-mapper", "", "Program to run to transforming metadata before upload", "Metadata")
 }
 
 // ParseHeaders converts the strings passed in via the header flags into HTTPOptions
diff --git a/fs/dump.go b/fs/dump.go
index 5837145cc..a461d660a 100644
--- a/fs/dump.go
+++ b/fs/dump.go
@@ -13,6 +13,7 @@ const (
 	DumpFilters
 	DumpGoRoutines
 	DumpOpenFiles
+	DumpMapper
 )
 
 type dumpChoices struct{}
@@ -27,6 +28,7 @@ func (dumpChoices) Choices() []BitsChoicesInfo {
 		{uint64(DumpFilters), "filters"},
 		{uint64(DumpGoRoutines), "goroutines"},
 		{uint64(DumpOpenFiles), "openfiles"},
+		{uint64(DumpMapper), "mapper"},
 	}
 }
 
diff --git a/fs/march/march.go b/fs/march/march.go
index 771f079f4..856be0e7c 100644
--- a/fs/march/march.go
+++ b/fs/march/march.go
@@ -35,6 +35,7 @@ type March struct {
 	srcListDir listDirFn // function to call to list a directory in the src
 	dstListDir listDirFn // function to call to list a directory in the dst
 	transforms []matchTransformFn
+	limiter    chan struct{} // make sure we don't do too many operations at once
 }
 
 // Marcher is called on each match
@@ -69,6 +70,8 @@ func (m *March) init(ctx context.Context) {
 	if m.Fdst.Features().CaseInsensitive || ci.IgnoreCaseSync {
 		m.transforms = append(m.transforms, strings.ToLower)
 	}
+	// Limit parallelism for operations
+	m.limiter = make(chan struct{}, ci.Checkers)
 }
 
 // list a directory into entries, err
@@ -429,13 +432,11 @@ func (m *March) processJob(job listDirJob) ([]listDirJob, error) {
 
 	// If NoTraverse is set, then try to find a matching object
 	// for each item in the srcList to head dst object
-	ci := fs.GetConfig(m.Ctx)
-	limiter := make(chan struct{}, ci.Checkers)
 	if m.NoTraverse && !m.NoCheckDest {
 		for _, src := range srcList {
 			wg.Add(1)
-			limiter <- struct{}{}
-			go func(limiter chan struct{}, src fs.DirEntry) {
+			m.limiter <- struct{}{}
+			go func(src fs.DirEntry) {
 				defer wg.Done()
 				if srcObj, ok := src.(fs.Object); ok {
 					leaf := path.Base(srcObj.Remote())
@@ -446,8 +447,8 @@ func (m *March) processJob(job listDirJob) ([]listDirJob, error) {
 						mu.Unlock()
 					}
 				}
-				<-limiter
-			}(limiter, src)
+				<-m.limiter
+			}(src)
 		}
 		wg.Wait()
 	}
diff --git a/fs/metadata.go b/fs/metadata.go
index 1fa48dc77..19b07cb63 100644
--- a/fs/metadata.go
+++ b/fs/metadata.go
@@ -1,6 +1,14 @@
 package fs
 
-import "context"
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"os/exec"
+	"strings"
+	"time"
+)
 
 // Metadata represents Object metadata in a standardised form
 //
@@ -66,12 +74,85 @@ func GetMetadata(ctx context.Context, o ObjectInfo) (metadata Metadata, err erro
 	return do.Metadata(ctx)
 }
 
+// mapItem descripts the item to be mapped
+type mapItem struct {
+	SrcFs     string
+	SrcFsType string
+	DstFs     string
+	DstFsType string
+	Remote    string
+	Size      int64
+	MimeType  string `json:",omitempty"`
+	ModTime   time.Time
+	IsDir     bool
+	ID        string   `json:",omitempty"`
+	Metadata  Metadata `json:",omitempty"`
+}
+
+// This runs an external program on the metadata which can be used to
+// map it from one form to another.
+func metadataMapper(ctx context.Context, cmdLine SpaceSepList, dstFs Fs, o ObjectInfo, metadata Metadata) (newMetadata Metadata, err error) {
+	ci := GetConfig(ctx)
+	cmd := exec.Command(cmdLine[0], cmdLine[1:]...)
+	in := mapItem{
+		DstFs:     ConfigString(dstFs),
+		DstFsType: Type(dstFs),
+		Remote:    o.Remote(),
+		Size:      o.Size(),
+		MimeType:  MimeType(ctx, o),
+		ModTime:   o.ModTime(ctx),
+		IsDir:     false,
+		Metadata:  metadata,
+	}
+	fInfo := o.Fs()
+	if f, ok := fInfo.(Fs); ok {
+		in.SrcFs = ConfigString(f)
+		in.SrcFsType = Type(f)
+	} else {
+		in.SrcFs = fInfo.Name() + ":" + fInfo.Root()
+		in.SrcFsType = "unknown"
+	}
+	if do, ok := o.(IDer); ok {
+		in.ID = do.ID()
+	}
+	inBytes, err := json.MarshalIndent(in, "", "\t")
+	if err != nil {
+		return nil, fmt.Errorf("metadata mapper: failed to marshal input: %w", err)
+	}
+	if ci.Dump.IsSet(DumpMapper) {
+		Debugf(nil, "Metadata mapper sent: \n%s\n", string(inBytes))
+	}
+	var stdout, stderr bytes.Buffer
+	cmd.Stdin = bytes.NewBuffer(inBytes)
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	start := time.Now()
+	err = cmd.Run()
+	Debugf(o, "Calling metadata mapper %v", cmdLine)
+	duration := time.Since(start)
+	if err != nil {
+		return nil, fmt.Errorf("metadata mapper: failed on %v: %q: %w", cmdLine, strings.TrimSpace(stderr.String()), err)
+	}
+	if ci.Dump.IsSet(DumpMapper) {
+		Debugf(nil, "Metadata mapper received: \n%s\n", stdout.String())
+	}
+	var out mapItem
+	err = json.Unmarshal(stdout.Bytes(), &out)
+	if err != nil {
+		return nil, fmt.Errorf("metadata mapper: failed to read output: %q: %w", stdout.String(), err)
+	}
+	Debugf(o, "Metadata mapper returned in %v", duration)
+	return out.Metadata, nil
+}
+
 // GetMetadataOptions from an ObjectInfo and merge it with any in options
 //
-// If --metadata isn't in use it will return nil
+// If --metadata isn't in use it will return nil.
 //
-// If the object has no metadata then metadata will be nil
-func GetMetadataOptions(ctx context.Context, o ObjectInfo, options []OpenOption) (metadata Metadata, err error) {
+// If the object has no metadata then metadata will be nil.
+//
+// This should be passed the destination Fs for the metadata mapper
+func GetMetadataOptions(ctx context.Context, dstFs Fs, o ObjectInfo, options []OpenOption) (metadata Metadata, err error) {
 	ci := GetConfig(ctx)
 	if !ci.Metadata {
 		return nil, nil
@@ -81,5 +162,11 @@ func GetMetadataOptions(ctx context.Context, o ObjectInfo, options []OpenOption)
 		return nil, err
 	}
 	metadata.MergeOptions(options)
+	if len(ci.MetadataMapper) != 0 {
+		metadata, err = metadataMapper(ctx, ci.MetadataMapper, dstFs, o, metadata)
+		if err != nil {
+			return nil, err
+		}
+	}
 	return metadata, nil
 }
diff --git a/fs/metadata_mapper_code.go b/fs/metadata_mapper_code.go
new file mode 100644
index 000000000..9f402df11
--- /dev/null
+++ b/fs/metadata_mapper_code.go
@@ -0,0 +1,75 @@
+//go:build ignore
+// +build ignore
+
+// A simple metadata mapper for testing purposes
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+)
+
+func check[T comparable](in map[string]any, key string, want T) {
+	value, ok := in[key]
+	if !ok {
+		fmt.Fprintf(os.Stderr, "%s key not found\n", key)
+		os.Exit(1)
+	}
+	if value.(T) != want {
+		fmt.Fprintf(os.Stderr, "%s wrong - expecting %s but got %s\n", key, want, value)
+		os.Exit(1)
+	}
+}
+
+func main() {
+	// Read the input
+	var in map[string]any
+	err := json.NewDecoder(os.Stdin).Decode(&in)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Check the input
+	metadata, ok := in["Metadata"]
+	if !ok {
+		fmt.Fprintf(os.Stderr, "Metadata key not found\n")
+		os.Exit(1)
+	}
+	check(in, "Size", 5.0)
+	check(in, "SrcFs", "memory:")
+	check(in, "SrcFsType", "object.memoryFs")
+	check(in, "DstFs", "dstFs:dstFsRoot")
+	check(in, "DstFsType", "mockfs")
+	check(in, "Remote", "file.txt")
+	check(in, "MimeType", "text/plain; charset=utf-8")
+	check(in, "ModTime", "2001-02-03T04:05:06.000000007Z")
+	check(in, "IsDir", false)
+	//check(in, "ID", "Potato")
+
+	// Map the metadata
+	metadataOut := map[string]string{}
+	var out = map[string]any{
+		"Metadata": metadataOut,
+	}
+	for k, v := range metadata.(map[string]any) {
+		switch k {
+		case "error":
+			fmt.Fprintf(os.Stderr, "Error: %s\n", v)
+			os.Exit(1)
+		case "key1":
+			v = "two " + v.(string)
+		case "key3":
+			continue
+		}
+		metadataOut[k] = v.(string)
+	}
+	metadataOut["key0"] = "cabbage"
+
+	// Write the output
+	json.NewEncoder(os.Stdout).Encode(&out)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/go.mod b/go.mod
index d3dd28c46..e63a50322 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.8.0
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0
+	github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.1.0
 	github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
 	github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd
 	github.com/Mikubill/gofakes3 v0.0.3-0.20230622102024-284c0f988700
@@ -86,6 +87,7 @@ require (
 	cloud.google.com/go/compute v1.23.2 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.4.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.1.0 // indirect
 	github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 // indirect
 	github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
 	github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
diff --git a/go.sum b/go.sum
index cc1e76895..16017f64e 100644
--- a/go.sum
+++ b/go.sum
@@ -46,6 +46,8 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.4.0/go.mod h1:s4kgfzA0covAXNic
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.2.0 h1:Ma67P/GGprNwsslzEH6+Kb8nybI8jpDTm4Wmzu2ReK8=
 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0 h1:gggzg0SUMs6SQbEw+3LoSsYf9YMjkupeAnHMX8O9mmY=
 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0/go.mod h1:+6KLcKIVgxoBDMqMO/Nvy7bZ9a0nbU3I1DtFQK3YvB4=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.1.0 h1:1MDP9LGZzH2Nd4NzS82YZpddy8xEvwkxpcVJz8/TffQ=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.1.0/go.mod h1:qTVVvsSlVe5NZKdjBOJYxB0Ge5D+laQga/zckme+hw0=
 github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
 github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
 github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 h1:hVeq+yCyUi+MsoO/CU95yqCIcdzra5ovzk8Q2BBpV2M=
diff --git a/vfs/vfscache/item.go b/vfs/vfscache/item.go
index 566e29e32..06a7454a5 100644
--- a/vfs/vfscache/item.go
+++ b/vfs/vfscache/item.go
@@ -593,6 +593,10 @@ func (item *Item) _store(ctx context.Context, storeFn StoreFn) (err error) {
 		o, err := operations.Copy(ctx, item.c.fremote, o, name, cacheObj)
 		item.mu.Lock()
 		if err != nil {
+			if errors.Is(err, fs.ErrorCantUploadEmptyFiles) {
+				fs.Errorf(name, "Writeback failed: %v", err)
+				return nil
+			}
 			return fmt.Errorf("vfs cache: failed to transfer file from cache to remote: %w", err)
 		}
 		item.o = o


Test patch:
diff --git a/backend/azurefiles/azurefiles_internal_test.go b/backend/azurefiles/azurefiles_internal_test.go
new file mode 100644
index 000000000..b123ad730
--- /dev/null
+++ b/backend/azurefiles/azurefiles_internal_test.go
@@ -0,0 +1,70 @@
+//go:build !plan9 && !js
+// +build !plan9,!js
+
+package azurefiles
+
+import (
+	"context"
+	"math/rand"
+	"strings"
+	"testing"
+
+	"github.com/rclone/rclone/fstest/fstests"
+	"github.com/stretchr/testify/assert"
+)
+
+func (f *Fs) InternalTest(t *testing.T) {
+	t.Run("Authentication", f.InternalTestAuth)
+}
+
+var _ fstests.InternalTester = (*Fs)(nil)
+
+func (f *Fs) InternalTestAuth(t *testing.T) {
+	t.Skip("skipping since this requires authentication credentials which are not part of repo")
+	shareName := "test-rclone-oct-2023"
+	testCases := []struct {
+		name    string
+		options *Options
+	}{
+		{
+			name: "ConnectionString",
+			options: &Options{
+				ShareName:        shareName,
+				ConnectionString: "",
+			},
+		},
+		{
+			name: "AccountAndKey",
+			options: &Options{
+				ShareName: shareName,
+				Account:   "",
+				Key:       "",
+			}},
+		{
+			name: "SASUrl",
+			options: &Options{
+				ShareName: shareName,
+				SASURL:    "",
+			}},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			fs, err := newFsFromOptions(context.TODO(), "TestAzureFiles", "", tc.options)
+			assert.NoError(t, err)
+			dirName := randomString(10)
+			assert.NoError(t, fs.Mkdir(context.TODO(), dirName))
+		})
+	}
+}
+
+const chars = "abcdefghijklmnopqrstuvwzyxABCDEFGHIJKLMNOPQRSTUVWZYX"
+
+func randomString(charCount int) string {
+	strBldr := strings.Builder{}
+	for i := 0; i < charCount; i++ {
+		randPos := rand.Int63n(52)
+		strBldr.WriteByte(chars[randPos])
+	}
+	return strBldr.String()
+}
diff --git a/backend/azurefiles/azurefiles_test.go b/backend/azurefiles/azurefiles_test.go
new file mode 100644
index 000000000..d8091fa49
--- /dev/null
+++ b/backend/azurefiles/azurefiles_test.go
@@ -0,0 +1,18 @@
+//go:build !plan9 && !js
+// +build !plan9,!js
+
+package azurefiles
+
+import (
+	"testing"
+
+	"github.com/rclone/rclone/fstest/fstests"
+)
+
+func TestIntegration(t *testing.T) {
+	var objPtr *Object
+	fstests.Run(t, &fstests.Opt{
+		RemoteName: "TestAzureFiles:",
+		NilObject:  objPtr,
+	})
+}
diff --git a/backend/linkbox/linkbox_test.go b/backend/linkbox/linkbox_test.go
new file mode 100644
index 000000000..aa03c79ca
--- /dev/null
+++ b/backend/linkbox/linkbox_test.go
@@ -0,0 +1,17 @@
+// Test Linkbox filesystem interface
+package linkbox_test
+
+import (
+	"testing"
+
+	"github.com/rclone/rclone/backend/linkbox"
+	"github.com/rclone/rclone/fstest/fstests"
+)
+
+// TestIntegration runs integration tests against the remote
+func TestIntegration(t *testing.T) {
+	fstests.Run(t, &fstests.Opt{
+		RemoteName: "TestLinkbox:",
+		NilObject:  (*linkbox.Object)(nil),
+	})
+}
diff --git a/bin/test_metadata_mapper.py b/bin/test_metadata_mapper.py
new file mode 100755
index 000000000..6a99ee49a
--- /dev/null
+++ b/bin/test_metadata_mapper.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+"""
+A demo metadata mapper
+"""
+
+import sys
+import json
+
+def main():
+    i = json.load(sys.stdin)
+    # Add tag to description
+    metadata = i["Metadata"]
+    if "description" in metadata:
+        metadata["description"] += " [migrated from domain1]"
+    else:
+        metadata["description"] = "[migrated from domain1]"
+    # Modify owner
+    if "owner" in metadata:
+        metadata["owner"] = metadata["owner"].replace("domain1.com", "domain2.com")
+    o = { "Metadata": metadata }
+    json.dump(o, sys.stdout, indent="\t")
+
+if __name__ == "__main__":
+    main()
diff --git a/fs/metadata_test.go b/fs/metadata_test.go
index ba982cbc1..7e5389fb6 100644
--- a/fs/metadata_test.go
+++ b/fs/metadata_test.go
@@ -1,14 +1,20 @@
-package fs
+package fs_test
 
 import (
+	"context"
 	"fmt"
 	"testing"
+	"time"
 
+	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fs/object"
+	"github.com/rclone/rclone/fstest/mockfs"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestMetadataSet(t *testing.T) {
-	var m Metadata
+	var m fs.Metadata
 	assert.Nil(t, m)
 	m.Set("key", "value")
 	assert.NotNil(t, m)
@@ -19,34 +25,34 @@ func TestMetadataSet(t *testing.T) {
 
 func TestMetadataMerge(t *testing.T) {
 	for _, test := range []struct {
-		in    Metadata
-		merge Metadata
-		want  Metadata
+		in    fs.Metadata
+		merge fs.Metadata
+		want  fs.Metadata
 	}{
 		{
-			in:    Metadata{},
-			merge: Metadata{},
-			want:  Metadata{},
+			in:    fs.Metadata{},
+			merge: fs.Metadata{},
+			want:  fs.Metadata{},
 		}, {
 			in:    nil,
 			merge: nil,
 			want:  nil,
 		}, {
 			in:    nil,
-			merge: Metadata{},
+			merge: fs.Metadata{},
 			want:  nil,
 		}, {
 			in:    nil,
-			merge: Metadata{"a": "1", "b": "2"},
-			want:  Metadata{"a": "1", "b": "2"},
+			merge: fs.Metadata{"a": "1", "b": "2"},
+			want:  fs.Metadata{"a": "1", "b": "2"},
 		}, {
-			in:    Metadata{"a": "1", "b": "2"},
+			in:    fs.Metadata{"a": "1", "b": "2"},
 			merge: nil,
-			want:  Metadata{"a": "1", "b": "2"},
+			want:  fs.Metadata{"a": "1", "b": "2"},
 		}, {
-			in:    Metadata{"a": "1", "b": "2"},
-			merge: Metadata{"b": "B", "c": "3"},
-			want:  Metadata{"a": "1", "b": "B", "c": "3"},
+			in:    fs.Metadata{"a": "1", "b": "2"},
+			merge: fs.Metadata{"b": "B", "c": "3"},
+			want:  fs.Metadata{"a": "1", "b": "B", "c": "3"},
 		},
 	} {
 		what := fmt.Sprintf("in=%v, merge=%v", test.in, test.merge)
@@ -57,36 +63,36 @@ func TestMetadataMerge(t *testing.T) {
 
 func TestMetadataMergeOptions(t *testing.T) {
 	for _, test := range []struct {
-		in   Metadata
-		opts []OpenOption
-		want Metadata
+		in   fs.Metadata
+		opts []fs.OpenOption
+		want fs.Metadata
 	}{
 		{
-			opts: []OpenOption{},
+			opts: []fs.OpenOption{},
 			want: nil,
 		}, {
-			opts: []OpenOption{&HTTPOption{}},
+			opts: []fs.OpenOption{&fs.HTTPOption{}},
 			want: nil,
 		}, {
-			opts: []OpenOption{MetadataOption{"a": "1", "b": "2"}},
-			want: Metadata{"a": "1", "b": "2"},
+			opts: []fs.OpenOption{fs.MetadataOption{"a": "1", "b": "2"}},
+			want: fs.Metadata{"a": "1", "b": "2"},
 		}, {
-			opts: []OpenOption{
-				&HTTPOption{},
-				MetadataOption{"a": "1", "b": "2"},
-				MetadataOption{"b": "B", "c": "3"},
-				&HTTPOption{},
+			opts: []fs.OpenOption{
+				&fs.HTTPOption{},
+				fs.MetadataOption{"a": "1", "b": "2"},
+				fs.MetadataOption{"b": "B", "c": "3"},
+				&fs.HTTPOption{},
 			},
-			want: Metadata{"a": "1", "b": "B", "c": "3"},
+			want: fs.Metadata{"a": "1", "b": "B", "c": "3"},
 		}, {
-			in: Metadata{"a": "first", "z": "OK"},
-			opts: []OpenOption{
-				&HTTPOption{},
-				MetadataOption{"a": "1", "b": "2"},
-				MetadataOption{"b": "B", "c": "3"},
-				&HTTPOption{},
+			in: fs.Metadata{"a": "first", "z": "OK"},
+			opts: []fs.OpenOption{
+				&fs.HTTPOption{},
+				fs.MetadataOption{"a": "1", "b": "2"},
+				fs.MetadataOption{"b": "B", "c": "3"},
+				&fs.HTTPOption{},
 			},
-			want: Metadata{"a": "1", "b": "B", "c": "3", "z": "OK"},
+			want: fs.Metadata{"a": "1", "b": "B", "c": "3", "z": "OK"},
 		},
 	} {
 		what := fmt.Sprintf("in=%v, opts=%v", test.in, test.opts)
@@ -94,3 +100,58 @@ func TestMetadataMergeOptions(t *testing.T) {
 		assert.Equal(t, test.want, test.in, what)
 	}
 }
+
+func TestMetadataMapper(t *testing.T) {
+	ctx := context.Background()
+	ctx, ci := fs.AddConfig(ctx)
+	ci.Metadata = true
+	require.NoError(t, ci.MetadataMapper.Set("go run metadata_mapper_code.go"))
+	now := time.Date(2001, 2, 3, 4, 5, 6, 7, time.UTC)
+	f, err := mockfs.NewFs(ctx, "dstFs", "dstFsRoot", nil)
+	require.NoError(t, err)
+
+	t.Run("Normal", func(t *testing.T) {
+		o := object.NewMemoryObject("file.txt", now, []byte("hello")).WithMetadata(fs.Metadata{
+			"key1": "potato",
+			"key2": "sausage",
+			"key3": "gravy",
+		})
+		metadata, err := fs.GetMetadataOptions(ctx, f, o, nil)
+		require.NoError(t, err)
+		assert.Equal(t, fs.Metadata{
+			"key0": "cabbage",
+			"key1": "two potato",
+			"key2": "sausage",
+		}, metadata)
+	})
+
+	t.Run("Error", func(t *testing.T) {
+		o := object.NewMemoryObject("file.txt", now, []byte("hello")).WithMetadata(fs.Metadata{
+			"error": "Red Alert",
+		})
+		metadata, err := fs.GetMetadataOptions(ctx, f, o, nil)
+		require.Error(t, err)
+		assert.ErrorContains(t, err, "Red Alert")
+		require.Nil(t, metadata)
+	})
+
+	t.Run("Merge", func(t *testing.T) {
+		o := object.NewMemoryObject("file.txt", now, []byte("hello")).WithMetadata(fs.Metadata{
+			"key1": "potato",
+			"key2": "sausage",
+			"key3": "gravy",
+		})
+		metadata, err := fs.GetMetadataOptions(ctx, f, o, []fs.OpenOption{fs.MetadataOption(fs.Metadata{
+			"option": "optionValue",
+			"key1":   "new potato",
+			"key2":   "salami",
+		})})
+		require.NoError(t, err)
+		assert.Equal(t, fs.Metadata{
+			"key0":   "cabbage",
+			"key1":   "two new potato",
+			"key2":   "salami",
+			"option": "optionValue",
+		}, metadata)
+	})
+}
diff --git a/fstest/fstests/fstests.go b/fstest/fstests/fstests.go
index cfc4cf069..9ffbca3c7 100644
--- a/fstest/fstests/fstests.go
+++ b/fstest/fstests/fstests.go
@@ -1462,6 +1462,8 @@ func Run(t *testing.T, opt *Opt) {
 			// TestObjectMetadata tests the Metadata of the object is correct
 			t.Run("ObjectMetadata", func(t *testing.T) {
 				skipIfNotOk(t)
+				ctx, ci := fs.AddConfig(ctx)
+				ci.Metadata = true
 				features := f.Features()
 				obj := findObject(ctx, t, f, file1.Path)
 				do, objectHasMetadata := obj.(fs.Metadataer)
@@ -1496,7 +1498,7 @@ func Run(t *testing.T, opt *Opt) {
 					if features.UserMetadata {
 						// check all the metadata bits we uploaded are present - there may be more we didn't write
 						for k, v := range file1Metadata {
-							assert.Equal(t, v, metadata[k], "can read and write metadata but failed on key %q", k)
+							assert.Equal(t, v, metadata[k], "can read and write metadata but failed on key %q (want=%+v, got=%+v)", k, file1Metadata, metadata)
 						}
 					}
 					// Now test we can set the mtime and content-type via the metadata and these take precedence
diff --git a/fstest/test_all/config.yaml b/fstest/test_all/config.yaml
index 38de401d7..3c77100c0 100644
--- a/fstest/test_all/config.yaml
+++ b/fstest/test_all/config.yaml
@@ -320,6 +320,8 @@ backends:
  - backend:  "azureblob"
    remote:   "TestAzureBlob,directory_markers:"
    fastlist: true
+ - backend:  "azurefiles"
+   remote:   "TestAzureFiles:"
  - backend:  "pcloud"
    remote:   "TestPcloud:"
    fastlist: true
@@ -372,6 +374,13 @@ backends:
  # - backend:  "koofr"
  #   remote:   "TestDigiStorage:"
  #   fastlist: false
+ - backend: "linkbox"
+   remote: "TestLinkbox:"
+   fastlist: false
+   ignore:
+     - TestIntegration/FsMkdir/FsEncoding/invalid_UTF-8
+     - TestRWFileHandleWriteNoWrite
+     - TestCaseInsensitiveMoveFile
  - backend:  "premiumizeme"
    remote:   "TestPremiumizeMe:"
    fastlist: false