Repository: C2FO/vfs
Pull request #58 (2021-04-21 16:32:28)

Add Azure Blob Storage implementation
Implement vfs interface for the Azure Blob Storage backend

dmcilvain44: Something else I wanted to add on this work.  I have started work on adding another method of authentication which includes a pretty significant refactor of how the client and options get initialized.  We may want to hold off on changes for those sections until I get that PR up.
funkyshu: @dmcilvain44  Please rebase and apply any linting changes needed.  See https://golangci-lint.run/usage/integrations/ for IDE plugin installation.

Patch:
diff --git a/backend/all/all.go b/backend/all/all.go
index d92a981..84489a7 100644
--- a/backend/all/all.go
+++ b/backend/all/all.go
@@ -2,9 +2,10 @@
 package all
 
 import (
-	_ "github.com/c2fo/vfs/v5/backend/gs"   // register gs backend
-	_ "github.com/c2fo/vfs/v5/backend/mem"  // register mem backend
-	_ "github.com/c2fo/vfs/v5/backend/os"   // register os backend
-	_ "github.com/c2fo/vfs/v5/backend/s3"   // register s3 backend
-	_ "github.com/c2fo/vfs/v5/backend/sftp" // register sftp backend
+	_ "github.com/c2fo/vfs/v5/backend/azure" // register azure backend
+	_ "github.com/c2fo/vfs/v5/backend/gs"    // register gs backend
+	_ "github.com/c2fo/vfs/v5/backend/mem"   // register mem backend
+	_ "github.com/c2fo/vfs/v5/backend/os"    // register os backend
+	_ "github.com/c2fo/vfs/v5/backend/s3"    // register s3 backend
+	_ "github.com/c2fo/vfs/v5/backend/sftp"  // register sftp backend
 )
diff --git a/backend/azure/client.go b/backend/azure/client.go
new file mode 100644
index 0000000..c818b19
--- /dev/null
+++ b/backend/azure/client.go
@@ -0,0 +1,210 @@
+package azure
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"log"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/Azure/azure-pipeline-go/pipeline"
+	"github.com/Azure/azure-storage-blob-go/azblob"
+
+	"github.com/c2fo/vfs/v5"
+	"github.com/c2fo/vfs/v5/utils"
+)
+
+// The Client interface contains methods that perform specific operations to Azure Blob Storage.  This interface is
+// here so we can write mocks over the actual functionality.
+type Client interface {
+	// Properties should return a BlobProperties struct for the blob specified by locationURI, and filePath.  If the
+	// blob is not found an error should be returned.
+	Properties(locationURI, filePath string) (*BlobProperties, error)
+
+	// SetMetadata should add the metadata specified by the parameter metadata for the blob specified by the parameter
+	// file.
+	SetMetadata(file vfs.File, metadata map[string]string) error
+
+	// Upload should create or update the blob specified by the file parameter with the contents of the content
+	// parameter
+	Upload(file vfs.File, content io.ReadSeeker) error
+
+	// Download should return a reader for the blob specified by the file parameter
+	Download(file vfs.File) (io.ReadCloser, error)
+
+	// Copy should copy the file specified by srcFile to the file specified by tgtFile
+	Copy(srcFile vfs.File, tgtFile vfs.File) error
+
+	// List should return a listing for the specified location.  Listings should include the full path for the file.
+	List(l vfs.Location) ([]string, error)
+
+	// Delete should delete the file specified by the parameter file.
+	Delete(file vfs.File) error
+}
+
+// ClientImpl is the main implementation that actually makes the calls to Azure Blob Storage
+type ClientImpl struct {
+	pipeline pipeline.Pipeline
+}
+
+// NewClient initializes a new ClientImpl
+func NewClient(accountName, accountKey string) (*ClientImpl, error) {
+	credential, err := azblob.NewSharedKeyCredential(accountName, accountKey)
+	if err != nil {
+		return nil, err
+	}
+
+	// This configures the client to use the default retry policy.  The default policy uses exponential backoff with
+	// maxRetries = 4.  If this behavior needs to be changed, add the Retry member to azblob.PipelineOptions.  For
+	// more information on azure retry policies see https://pkg.go.dev/github.com/Azure/azure-storage-blob-go/azblob#RetryOptions
+	p := azblob.NewPipeline(credential, azblob.PipelineOptions{})
+
+	return &ClientImpl{pipeline: p}, nil
+}
+
+// Properties fetches the properties for the blob specified by the parameters containerURI and filePath
+func (a *ClientImpl) Properties(containerURI, filePath string) (*BlobProperties, error) {
+	URL, err := url.Parse(containerURI)
+	if err != nil {
+		return nil, err
+	}
+	containerURL := azblob.NewContainerURL(*URL, a.pipeline)
+
+	if filePath == "" {
+		// this is only used to check for the existence of a container so we don't care about anything but the
+		// error
+		_, err := containerURL.GetProperties(context.Background(), azblob.LeaseAccessConditions{})
+		if err != nil {
+			return nil, err
+		}
+		return nil, nil
+	}
+
+	blobURL := containerURL.NewBlockBlobURL(utils.RemoveLeadingSlash(filePath))
+	resp, err := blobURL.GetProperties(context.Background(), azblob.BlobAccessConditions{}, azblob.ClientProvidedKeyOptions{})
+	if err != nil {
+		return nil, err
+	}
+	return NewBlobProperties(resp), nil
+}
+
+// Upload uploads a new file to Azure Blob Storage
+func (a *ClientImpl) Upload(file vfs.File, content io.ReadSeeker) error {
+	URL, err := url.Parse(file.Location().(*Location).ContainerURL())
+	if err != nil {
+		return err
+	}
+
+	containerURL := azblob.NewContainerURL(*URL, a.pipeline)
+	blobURL := containerURL.NewBlockBlobURL(utils.RemoveLeadingSlash(file.Path()))
+	_, err = blobURL.Upload(context.Background(), content, azblob.BlobHTTPHeaders{}, azblob.Metadata{},
+		azblob.BlobAccessConditions{}, azblob.DefaultAccessTier, nil, azblob.ClientProvidedKeyOptions{})
+	return err
+}
+
+// SetMetadata sets the given metadata for the blob
+func (a *ClientImpl) SetMetadata(file vfs.File, metadata map[string]string) error {
+	URL, err := url.Parse(file.Location().(*Location).ContainerURL())
+	if err != nil {
+		return err
+	}
+
+	containerURL := azblob.NewContainerURL(*URL, a.pipeline)
+	blobURL := containerURL.NewBlockBlobURL(utils.RemoveLeadingSlash(file.Path()))
+	_, err = blobURL.SetMetadata(context.Background(), metadata, azblob.BlobAccessConditions{}, azblob.ClientProvidedKeyOptions{})
+	return err
+}
+
+// Download returns an io.ReadCloser for the given vfs.File
+func (a *ClientImpl) Download(file vfs.File) (io.ReadCloser, error) {
+	URL, err := url.Parse(file.Location().(*Location).ContainerURL())
+	if err != nil {
+		return nil, err
+	}
+
+	containerURL := azblob.NewContainerURL(*URL, a.pipeline)
+	blobURL := containerURL.NewBlockBlobURL(utils.RemoveLeadingSlash(file.Path()))
+	get, err := blobURL.Download(context.Background(), 0, 0, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{})
+	if err != nil {
+		return nil, err
+	}
+	return get.Body(azblob.RetryReaderOptions{}), nil
+}
+
+// Copy copies srcFile to the destination tgtFile within Azure Blob Storage.  Note that in the case where we get
+// encoded spaces in the file name (i.e. %20) the '%' must be encoded or the copy command will return a not found
+// error.
+func (a *ClientImpl) Copy(srcFile, tgtFile vfs.File) error {
+	// Can't use url.PathEscape here since that will escape everything (even the directory separators)
+	srcURL, err := url.Parse(strings.Replace(srcFile.URI(), "%", "%25", -1))
+	if err != nil {
+		return err
+	}
+
+	tgtURL, err := url.Parse(tgtFile.Location().(*Location).ContainerURL())
+	if err != nil {
+		return err
+	}
+
+	containerURL := azblob.NewContainerURL(*tgtURL, a.pipeline)
+	blobURL := containerURL.NewBlockBlobURL(utils.RemoveLeadingSlash(tgtFile.Path()))
+	ctx := context.Background()
+	resp, err := blobURL.StartCopyFromURL(ctx, *srcURL, azblob.Metadata{}, azblob.ModifiedAccessConditions{},
+		azblob.BlobAccessConditions{}, azblob.DefaultAccessTier, nil)
+	if err != nil {
+		return err
+	}
+
+	for resp.CopyStatus() == azblob.CopyStatusPending {
+		time.Sleep(2 * time.Second)
+	}
+
+	if resp.CopyStatus() == azblob.CopyStatusSuccess {
+		return nil
+	}
+
+	return fmt.Errorf("copy failed  ERROR[%s]", resp.ErrorCode())
+}
+
+// List will return a listing of the contents of the given location.  Each item in the list will contain the full key
+// as specified by the azure blob (incliding the virtual 'path').
+func (a *ClientImpl) List(l vfs.Location) ([]string, error) {
+	URL, err := url.Parse(l.(*Location).ContainerURL())
+	if err != nil {
+		return []string{}, err
+	}
+
+	containerURL := azblob.NewContainerURL(*URL, a.pipeline)
+	ctx := context.Background()
+	var list []string
+	for marker := (azblob.Marker{}); marker.NotDone(); {
+		listBlob, err := containerURL.ListBlobsHierarchySegment(ctx, marker, "/",
+			azblob.ListBlobsSegmentOptions{Prefix: utils.RemoveLeadingSlash(l.Path())})
+		if err != nil {
+			log.Fatal("Unable to list blobs.  error: " + err.Error())
+		}
+
+		marker = listBlob.NextMarker
+
+		for i := range listBlob.Segment.BlobItems {
+			list = append(list, listBlob.Segment.BlobItems[i].Name)
+		}
+	}
+
+	return list, nil
+}
+
+// Delete deletes the given file from Azure Blob Storage.
+func (a *ClientImpl) Delete(file vfs.File) error {
+	URL, err := url.Parse(file.Location().(*Location).ContainerURL())
+	if err != nil {
+		return err
+	}
+
+	containerURL := azblob.NewContainerURL(*URL, a.pipeline)
+	blobURL := containerURL.NewBlockBlobURL(utils.RemoveLeadingSlash(file.Path()))
+	_, err = blobURL.Delete(context.Background(), azblob.DeleteSnapshotsOptionNone, azblob.BlobAccessConditions{})
+	return err
+}
diff --git a/backend/azure/doc.go b/backend/azure/doc.go
new file mode 100644
index 0000000..9c7e95a
--- /dev/null
+++ b/backend/azure/doc.go
@@ -0,0 +1,63 @@
+/*
+Package azure Microsoft Azure Blob Storage VFS Implementation
+
+Usage
+
+Rely on github.com/c2fo/vfs/backend
+
+  import(
+      "github.com/c2fo/vfs/v5/backend"
+      "github.com/c2fo/vfs/v5/backend/azure"
+  )
+
+  func UseFs() error {
+      fs := backend.Backend(azure.Scheme)
+      ...
+  }
+
+Or call directly:
+
+  import "github.com/c2fo/vfs/v5/backend/azure"
+
+  func DoSomething() {
+      fs := azure.NewFilesystem()
+      ...
+  }
+
+azure can be augmented with the following implementation-specific methods.  Backend returns vfs.Filesystem interface so it
+would have to be cast as azure.Filesystem to use the following:
+
+  func DoSomething() {
+
+      ...
+
+      // cast if fs was created using backend.Backend().  Not necessary if created directly from azure.NewFilesystem().
+      fs = fs.(azure.Filesystem)
+
+      // to use your own "context"
+      ctx := context.Background()
+      fs = fs.WithContext(ctx)
+
+      // to pass in client options
+      fs = fs.WithOptions(
+          azure.Options{
+              AccountName: "...",
+              AccountKey: "...
+          },
+      )
+
+      // to pass specific client, for instance mock client
+      ctx := context.Background()
+      client, _ := storage.NewClient(ctx, MockAzureClient{...})
+      fs = fs.WithClient(client)
+  }
+
+Authentication
+
+Authentication, by default, occurs automatically when Client() is called. It looks for credentials in the following places,
+preferring the first location found:
+
+  1. The ENV vars AZURE_STORAGE_ACCOUNT and AZURE_STORAGE_KEY
+  2. More to come...
+*/
+package azure
diff --git a/backend/azure/file.go b/backend/azure/file.go
new file mode 100644
index 0000000..f72b7e6
--- /dev/null
+++ b/backend/azure/file.go
@@ -0,0 +1,316 @@
+package azure
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path"
+	"strings"
+	"time"
+
+	"github.com/Azure/azure-storage-blob-go/azblob"
+
+	"github.com/c2fo/vfs/v5"
+	"github.com/c2fo/vfs/v5/utils"
+)
+
+// File implements the vfs.File interface for Azure Blob Storage
+type File struct {
+	fileSystem *FileSystem
+	container  string
+	name       string
+	tempFile   *os.File
+	isDirty    bool
+}
+
+// Close cleans up all of the backing data structures used for reading/writing files.  This includes, closing the
+// temp file, uploading the contents of the temp file to Azure Blob Storage (if necessary), and calling Seek(0, 0).
+func (f *File) Close() error {
+	if f.tempFile != nil {
+		defer func() {
+			f.tempFile.Close()
+			f.tempFile = nil
+			f.isDirty = false
+		}()
+
+		client, err := f.fileSystem.Client()
+		if err != nil {
+			return err
+		}
+
+		if _, err := f.Seek(0, 0); err != nil {
+			return err
+		}
+
+		if f.isDirty {
+			if err := client.Upload(f, f.tempFile); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// Read implements the io.Reader interface.  For this to work with Azure Blob Storage, a temporary local copy of
+// the file is created and read operations are performed against that.  The temp file is closed and flushed to Azure
+// when f.Close() is called.
+func (f *File) Read(p []byte) (n int, err error) {
+	if err := f.checkTempFile(); err != nil {
+		return 0, err
+	}
+	return f.tempFile.Read(p)
+}
+
+// Seek implements the io.Seeker interface.  For this to work with Azure Blob Storage, a temporary local copy of
+// the file is created and operations are performed against that.  The temp file is closed and flushed to Azure
+// when f.Close() is called.
+func (f *File) Seek(offset int64, whence int) (int64, error) {
+	if err := f.checkTempFile(); err != nil {
+		return 0, nil
+	}
+	return f.tempFile.Seek(offset, whence)
+}
+
+// Write implements the io.Writer interface.  Writes are performed against a temporary local file.  The temp file is
+// closed and flushed to Azure with f.Close() is called.
+func (f *File) Write(p []byte) (int, error) {
+	if err := f.checkTempFile(); err != nil {
+		return 0, err
+	}
+
+	n, err := f.tempFile.Write(p)
+	if err != nil {
+		return 0, err
+	}
+
+	f.isDirty = true
+
+	return n, nil
+}
+
+// String returns the file URI
+func (f *File) String() string {
+	return f.URI()
+}
+
+// Exists returns true/false if the file exists/does not exist on Azure
+func (f *File) Exists() (bool, error) {
+	client, err := f.fileSystem.Client()
+	if err != nil {
+		return false, err
+	}
+	_, err = client.Properties(f.Location().(*Location).ContainerURL(), f.Path())
+	if err != nil {
+		if err.(azblob.StorageError).ServiceCode() != "BlobNotFound" {
+			return false, err
+		}
+		return false, nil
+	}
+	return true, nil
+}
+
+// Location returns a Location instance for the files current location
+func (f *File) Location() vfs.Location {
+	return vfs.Location(&Location{
+		fileSystem: f.fileSystem,
+		container:  f.container,
+		path:       path.Dir(f.name),
+	})
+}
+
+// CopyToLocation creates a copy of *File, using the file's current name as the new file's
+// name at the given location. If the given location is also azure, the azure API for copying
+// files will be utilized, otherwise, standard io.Copy will be done to the new file.
+func (f *File) CopyToLocation(location vfs.Location) (vfs.File, error) {
+	newFile, err := location.NewFile(utils.RemoveLeadingSlash(f.Name()))
+	if err != nil {
+		return nil, err
+	}
+
+	if err := f.CopyToFile(newFile); err != nil {
+		return nil, err
+	}
+
+	return newFile, nil
+}
+
+// CopyToFile puts the contents of the receiver (f *File) into the passed vfs.File parameter.
+func (f *File) CopyToFile(file vfs.File) error {
+	azFile, ok := file.(*File)
+	if ok {
+		if f.isSameAuth(azFile) {
+			client, err := f.fileSystem.Client()
+			if err != nil {
+				return err
+			}
+			return client.Copy(f, file)
+		}
+	}
+
+	if err := utils.TouchCopy(file, f); err != nil {
+		return err
+	}
+
+	if err := file.Close(); err != nil {
+		return err
+	}
+
+	return f.Close()
+}
+
+// MoveToLocation copies the receiver to the passed location.  After the copy succeeds, the original is deleted.
+func (f *File) MoveToLocation(location vfs.Location) (vfs.File, error) {
+	newFile, err := f.CopyToLocation(location)
+	if err != nil {
+		return nil, err
+	}
+
+	return newFile, f.Delete()
+}
+
+// MoveToFile copies the receiver to the specified file and deletes the original file.
+func (f *File) MoveToFile(file vfs.File) error {
+	if err := f.CopyToFile(file); err != nil {
+		return err
+	}
+
+	return f.Delete()
+}
+
+// Delete deletes the file.
+func (f *File) Delete() error {
+	if err := f.Close(); err != nil {
+		return err
+	}
+
+	client, err := f.fileSystem.Client()
+	if err != nil {
+		return err
+	}
+	return client.Delete(f)
+}
+
+// LastModified returns the last modified time as a time.Time
+func (f *File) LastModified() (*time.Time, error) {
+	client, err := f.fileSystem.Client()
+	if err != nil {
+		return nil, err
+	}
+	props, err := client.Properties(f.Location().(*Location).ContainerURL(), f.Path())
+	if err != nil {
+		return nil, err
+	}
+	return props.LastModified, nil
+}
+
+// Size returns the size of the blob
+func (f *File) Size() (uint64, error) {
+	client, err := f.fileSystem.Client()
+	if err != nil {
+		return 0, err
+	}
+	props, err := client.Properties(f.Location().(*Location).ContainerURL(), f.Path())
+	if err != nil {
+		return 0, err
+	}
+	return props.Size, nil
+}
+
+// Path returns full path with leading slash.
+func (f *File) Path() string {
+	return f.name
+}
+
+// Name returns the name of the file
+func (f *File) Name() string {
+	return path.Base(f.name)
+}
+
+// Touch creates a zero-length file on the vfs.File if no File exists.  If the file exists, Touch updates the file's
+// last modified parameter.
+func (f *File) Touch() error {
+	exists, err := f.Exists()
+	if err != nil {
+		return err
+	}
+
+	client, err := f.fileSystem.Client()
+	if err != nil {
+		return err
+	}
+
+	if !exists {
+		return client.Upload(f, strings.NewReader(""))
+	}
+
+	props, err := client.Properties(f.Location().(*Location).ContainerURL(), f.Path())
+	if err != nil {
+		return err
+	}
+
+	newMetadata := make(map[string]string)
+	newMetadata["updated"] = "true"
+	if err := client.SetMetadata(f, newMetadata); err != nil {
+		return err
+	}
+
+	if err := client.SetMetadata(f, props.Metadata); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// URI returns a full Azure URI for the file
+func (f *File) URI() string {
+	return fmt.Sprintf("%s://%s%s", f.fileSystem.Scheme(), utils.EnsureTrailingSlash(f.fileSystem.Host()), path.Join(f.container, f.name))
+}
+
+func (f *File) checkTempFile() error {
+	if f.tempFile == nil {
+		client, err := f.fileSystem.Client()
+		if err != nil {
+			return err
+		}
+
+		exists, err := f.Exists()
+		if err != nil {
+			return err
+		}
+		if !exists {
+			tf, tfErr := ioutil.TempFile("", fmt.Sprintf("%s.%d", path.Base(f.Name()), time.Now().UnixNano()))
+			if tfErr != nil {
+				return tfErr
+			}
+			f.tempFile = tf
+		} else {
+			reader, dlErr := client.Download(f)
+			if dlErr != nil {
+				return dlErr
+			}
+
+			tf, tfErr := ioutil.TempFile("", fmt.Sprintf("%s.%d", path.Base(f.Name()), time.Now().UnixNano()))
+			if tfErr != nil {
+				return tfErr
+			}
+
+			if _, err := io.Copy(tf, reader); err != nil {
+				return err
+			}
+
+			if _, err := tf.Seek(0, 0); err != nil {
+				return err
+			}
+
+			f.tempFile = tf
+		}
+	}
+	return nil
+}
+
+func (f *File) isSameAuth(target *File) bool {
+	sourceOptions := f.fileSystem.options
+	targetOptions := target.fileSystem.options
+	return sourceOptions.AccountKey == targetOptions.AccountKey
+}
diff --git a/backend/azure/fileSystem.go b/backend/azure/fileSystem.go
new file mode 100644
index 0000000..87f3ee5
--- /dev/null
+++ b/backend/azure/fileSystem.go
@@ -0,0 +1,150 @@
+package azure
+
+import (
+	"errors"
+	"fmt"
+	"net/url"
+	"os"
+	"path"
+	"regexp"
+	"strings"
+
+	"github.com/c2fo/vfs/v5"
+	"github.com/c2fo/vfs/v5/backend"
+	"github.com/c2fo/vfs/v5/utils"
+)
+
+// Scheme defines the scheme for the azure implementation
+const Scheme = "https"
+
+// Name defines the name for the azure implementation
+const Name = "azure"
+
+const errNilFileSystemReceiver = "azure.FileSystem receiver pointer must be non-nil"
+
+// FileSystem implements the vfs.FileSystem interface for Azure Blob Storage
+type FileSystem struct {
+	options Options
+	client  Client
+}
+
+// NewFileSystem creates a new default FileSystem.  This will set the options options.AccountName and
+// options.AccountKey with the env variables AZURE_STORAGE_ACCOUNT and AZURE_STORAGE_ACCESS_KEY respectively.
+func NewFileSystem() *FileSystem {
+	fs := FileSystem{}
+	fs.options.AccountName, fs.options.AccountKey = os.Getenv("AZURE_STORAGE_ACCOUNT"), os.Getenv("AZURE_STORAGE_ACCESS_KEY")
+	return &fs
+}
+
+// WithOptions allows the caller to override the default options
+func (fs *FileSystem) WithOptions(opts vfs.Options) *FileSystem {
+	azureOpts, _ := opts.(Options)
+	fs.options = azureOpts
+	return fs
+}
+
+// WithClient allows the caller to specify a specific client to be used
+func (fs *FileSystem) WithClient(client Client) *FileSystem {
+	fs.client = client
+	return fs
+}
+
+// Client returns a Client to perform operations against Azure Blob Storage
+func (fs *FileSystem) Client() (Client, error) {
+	if fs.client == nil {
+		client, err := NewClient(fs.options.AccountName, fs.options.AccountKey)
+		fs.client = client
+		return fs.client, err
+	}
+	return fs.client, nil
+}
+
+// NewFile returns the azure implementation of vfs.File
+func (fs *FileSystem) NewFile(volume, absFilePath string) (vfs.File, error) {
+	if fs == nil {
+		return nil, errors.New(errNilFileSystemReceiver)
+	}
+
+	if volume == "" || absFilePath == "" {
+		return nil, errors.New("non-empty strings for container and path are required")
+	}
+
+	if err := utils.ValidateAbsoluteFilePath(absFilePath); err != nil {
+		return nil, err
+	}
+
+	return &File{
+		fileSystem: fs,
+		container:  volume,
+		name:       path.Clean(absFilePath),
+	}, nil
+}
+
+// NewLocation returns the azure implementation of vfs.Location
+func (fs *FileSystem) NewLocation(volume, absLocPath string) (vfs.Location, error) {
+	if fs == nil {
+		return nil, errors.New(errNilFileSystemReceiver)
+	}
+
+	if volume == "" || absLocPath == "" {
+		return nil, errors.New("non-empty strings for container and path are required")
+	}
+
+	if err := utils.ValidateAbsoluteLocationPath(absLocPath); err != nil {
+		return nil, err
+	}
+
+	return &Location{
+		fileSystem: fs,
+		container:  volume,
+		path:       path.Clean(absLocPath),
+	}, nil
+}
+
+// Name returns "azure"
+func (fs *FileSystem) Name() string {
+	return Name
+}
+
+// Scheme returns "https" as the initial part of the URI i.e. https://..
+func (fs *FileSystem) Scheme() string {
+	return Scheme
+}
+
+// Host returns the host portion of the URI.  For azure this consists of <account_name>.blob.core.windows.net.
+func (fs *FileSystem) Host() string {
+	return fmt.Sprintf("%s.blob.core.windows.net", fs.options.AccountName)
+}
+
+// Retry returns the default retry function.  This is overridable via the WithOptions function.
+func (fs *FileSystem) Retry() vfs.Retry {
+	if fs.options.RetryFunc != nil {
+		return fs.options.RetryFunc
+	}
+	return vfs.DefaultRetryer()
+}
+
+func init() {
+	// registers a default FileSystem
+	backend.Register(Scheme, NewFileSystem())
+}
+
+// ParsePath is a utility function used by vfssiple to separate the host from the path.  The first parameter returned
+// is the host and the second parameter is the path.
+func ParsePath(p string) (host, pth string, err error) {
+	if p == "/" {
+		return "", "", errors.New("no container specified for Azure path")
+	}
+	l := strings.Split(p, string(os.PathSeparator))
+	return l[1], utils.EnsureTrailingSlash(utils.EnsureLeadingSlash(path.Join(l[2:]...))), nil
+}
+
+// IsValidURI us a utility function used by vfssimple to determine if the given URI is a valid Azure URI
+func IsValidURI(u *url.URL) bool {
+	r := regexp.MustCompile(`.*\.blob\.core\.windows\.net`)
+
+	if u.Scheme == Scheme && r.MatchString(u.Host) {
+		return true
+	}
+	return false
+}
diff --git a/backend/azure/location.go b/backend/azure/location.go
new file mode 100644
index 0000000..1f6c078
--- /dev/null
+++ b/backend/azure/location.go
@@ -0,0 +1,205 @@
+package azure
+
+import (
+	"errors"
+	"fmt"
+	"path"
+	"regexp"
+	"strings"
+
+	"github.com/c2fo/vfs/v5"
+	"github.com/c2fo/vfs/v5/utils"
+)
+
+const errNilLocationReceiver = "azure.Location receiver pointer must be non-nil"
+
+// Location is the azure implementation of vfs.Location
+type Location struct {
+	container  string
+	path       string
+	fileSystem *FileSystem
+}
+
+// String returns the URI
+func (l *Location) String() string {
+	return l.URI()
+}
+
+// List returns a list of base names for the given location.
+func (l *Location) List() ([]string, error) {
+	client, err := l.fileSystem.Client()
+	if err != nil {
+		return nil, err
+	}
+	list, err := client.List(l)
+	if err != nil {
+		return nil, err
+	}
+
+	var ret []string
+	for _, item := range list {
+		ret = append(ret, path.Base(item))
+	}
+
+	if len(ret) == 0 {
+		return []string{}, nil
+	}
+
+	return ret, nil
+}
+
+// ListByPrefix returns a list of base names that contain the given prefix
+func (l *Location) ListByPrefix(prefix string) ([]string, error) {
+	if strings.Contains(prefix, "/") {
+		listLoc, err := l.NewLocation(utils.EnsureTrailingSlash(path.Dir(prefix)))
+		if err != nil {
+			return nil, err
+		}
+
+		return listLocationByPrefix(listLoc.(*Location), path.Base(prefix))
+	}
+
+	return listLocationByPrefix(l, prefix)
+}
+
+func listLocationByPrefix(location *Location, prefix string) ([]string, error) {
+	listing, err := location.List()
+	if err != nil {
+		return nil, err
+	}
+
+	var filtered []string
+	for _, item := range listing {
+		if strings.HasPrefix(item, prefix) {
+			filtered = append(filtered, path.Base(item))
+		}
+	}
+
+	if len(filtered) == 0 {
+		return []string{}, nil
+	}
+
+	return filtered, nil
+}
+
+// ListByRegex returns a list of base names that match the given regular expression
+func (l *Location) ListByRegex(regex *regexp.Regexp) ([]string, error) {
+	listing, err := l.List()
+	if err != nil {
+		return nil, err
+	}
+
+	var filtered []string
+	for _, item := range listing {
+		if regex.MatchString(item) {
+			filtered = append(filtered, path.Base(item))
+		}
+	}
+
+	if len(filtered) == 0 {
+		return []string{}, nil
+	}
+
+	return filtered, nil
+}
+
+// Volume returns the azure container.  Azure containers are equivalent to AWS Buckets
+func (l *Location) Volume() string {
+	return l.container
+}
+
+// Path returns the absolute path for the Location
+func (l *Location) Path() string {
+	return utils.EnsureTrailingSlash(utils.EnsureLeadingSlash(l.path))
+}
+
+// Exists returns true if the file exists and false.  In the case of errors false is always returned along with
+// the error
+func (l *Location) Exists() (bool, error) {
+	client, err := l.fileSystem.Client()
+	if err != nil {
+		return false, err
+	}
+	_, err = client.Properties(l.ContainerURL(), "")
+	if err != nil {
+		return false, nil
+	}
+	return true, nil
+}
+
+// NewLocation creates a new location instance relative to the current location's path.
+func (l *Location) NewLocation(relLocPath string) (vfs.Location, error) {
+	if l == nil {
+		return nil, errors.New(errNilLocationReceiver)
+	}
+
+	if err := utils.ValidateRelativeLocationPath(relLocPath); err != nil {
+		return nil, err
+	}
+
+	return &Location{
+		fileSystem: l.fileSystem,
+		container:  l.container,
+		path:       path.Join(l.path, relLocPath),
+	}, nil
+}
+
+// ChangeDir changes the current location's path to the new, relative path.
+func (l *Location) ChangeDir(relLocPath string) error {
+	if l == nil {
+		return errors.New(errNilLocationReceiver)
+	}
+
+	err := utils.ValidateRelativeLocationPath(relLocPath)
+	if err != nil {
+		return err
+	}
+
+	l.path = path.Join(l.path, relLocPath)
+
+	return nil
+}
+
+// FileSystem returns the azure FileSystem instance
+func (l *Location) FileSystem() vfs.FileSystem {
+	return l.fileSystem
+}
+
+// NewFile returns a new file instance at the given path, relative to the current location.
+func (l *Location) NewFile(relFilePath string) (vfs.File, error) {
+	if l == nil {
+		return nil, errors.New(errNilLocationReceiver)
+	}
+
+	if err := utils.ValidateRelativeFilePath(relFilePath); err != nil {
+		return nil, err
+	}
+
+	return &File{
+		name:       utils.EnsureLeadingSlash(path.Join(l.path, relFilePath)),
+		container:  l.container,
+		fileSystem: l.fileSystem,
+	}, nil
+}
+
+// DeleteFile deletes the file at the given path, relative to the current location.
+func (l *Location) DeleteFile(relFilePath string) error {
+	file, err := l.NewFile(utils.RemoveLeadingSlash(relFilePath))
+	if err != nil {
+		return err
+	}
+
+	return file.Delete()
+}
+
+// URI returns a URI string for the azure location.
+func (l *Location) URI() string {
+	return fmt.Sprintf("%s://%s%s", l.fileSystem.Scheme(), utils.EnsureTrailingSlash(l.fileSystem.Host()),
+		utils.EnsureTrailingSlash(path.Join(l.container, l.path)))
+}
+
+// ContainerURL returns the URL for the Azure Blob Storage container.
+func (l *Location) ContainerURL() string {
+	return fmt.Sprintf("%s://%s%s", l.fileSystem.Scheme(), utils.EnsureTrailingSlash(l.fileSystem.Host()),
+		utils.EnsureTrailingSlash(l.container))
+}
diff --git a/backend/azure/mock_client.go b/backend/azure/mock_client.go
new file mode 100644
index 0000000..f603a85
--- /dev/null
+++ b/backend/azure/mock_client.go
@@ -0,0 +1,92 @@
+package azure
+
+import (
+	"io"
+	"net/http"
+
+	"github.com/Azure/azure-storage-blob-go/azblob"
+
+	"github.com/c2fo/vfs/v5"
+)
+
+// MockAzureClient is a mock implementation of azure.Client.
+type MockAzureClient struct {
+	PropertiesError  error
+	PropertiesResult *BlobProperties
+	ExpectedError    error
+	ExpectedResult   interface{}
+}
+
+// Properties returns a PropertiesResult if it exists, otherwise it will return the value of PropertiesError
+func (a *MockAzureClient) Properties(locationURI, filePath string) (*BlobProperties, error) {
+	if a.PropertiesResult == nil {
+		return nil, a.PropertiesError
+	}
+	return a.PropertiesResult, a.PropertiesError
+}
+
+// SetMetadata returns the value of ExpectedError
+func (a *MockAzureClient) SetMetadata(file vfs.File, metadata map[string]string) error {
+	return a.ExpectedError
+}
+
+// Upload returns the value of ExpectedError
+func (a *MockAzureClient) Upload(file vfs.File, content io.ReadSeeker) error {
+	return a.ExpectedError
+}
+
+// Download returns ExpectedResult if it exists, otherwise it returns ExpectedError
+func (a *MockAzureClient) Download(file vfs.File) (io.ReadCloser, error) {
+	if a.ExpectedResult != nil {
+		return a.ExpectedResult.(io.ReadCloser), nil
+	}
+	return nil, a.ExpectedError
+}
+
+// Copy returns the value of ExpectedError
+func (a *MockAzureClient) Copy(srcFile, tgtFile vfs.File) error {
+	return a.ExpectedError
+}
+
+// List returns the value of ExpectedResult if it exists, otherwise it returns ExpectedError.
+func (a *MockAzureClient) List(l vfs.Location) ([]string, error) {
+	if a.ExpectedResult != nil {
+		return a.ExpectedResult.([]string), nil
+	}
+	return nil, a.ExpectedError
+}
+
+// Delete returns the value of ExpectedError
+func (a *MockAzureClient) Delete(file vfs.File) error {
+	return a.ExpectedError
+}
+
+// MockStorageError is a mock for the azblob.StorageError interface
+type MockStorageError struct {
+	azblob.ResponseError
+}
+
+// ServiceCode always returns "BlobNotFound" to simulate the not found condition
+func (mse MockStorageError) ServiceCode() azblob.ServiceCodeType {
+	return "BlobNotFound"
+}
+
+// Response returns nil
+func (mse MockStorageError) Response() *http.Response {
+	return nil
+}
+
+// Timeout returns nil
+func (mse MockStorageError) Timeout() bool {
+	return false
+}
+
+// Temporary returns nil
+func (mse MockStorageError) Temporary() bool {
+	return false
+}
+
+// Error returns empty string
+func (mse MockStorageError) Error() string {
+	return ""
+}
diff --git a/backend/azure/options.go b/backend/azure/options.go
new file mode 100644
index 0000000..c6ee27f
--- /dev/null
+++ b/backend/azure/options.go
@@ -0,0 +1,15 @@
+package azure
+
+import "github.com/c2fo/vfs/v5"
+
+// Options contains options necessary for the azure vfs implementation
+type Options struct {
+	// AccountName holds the Azure Blob Storage account name for authentication
+	AccountName string
+
+	// AccountKey holds the Azure Blob Storage account key for authentication
+	AccountKey string
+
+	// RetryFunc holds the retry function
+	RetryFunc vfs.Retry
+}
diff --git a/backend/azure/properties.go b/backend/azure/properties.go
new file mode 100644
index 0000000..d2fe9c0
--- /dev/null
+++ b/backend/azure/properties.go
@@ -0,0 +1,29 @@
+package azure
+
+import (
+	"time"
+
+	"github.com/Azure/azure-storage-blob-go/azblob"
+)
+
+// BlobProperties holds a subset of information returned by Blob.GetProperties(..)
+type BlobProperties struct {
+	// Size holds the size of the blob.
+	Size uint64
+
+	// LastModified holds the last modified time.Time
+	LastModified *time.Time
+
+	// Metadata holds the Azure metadata
+	Metadata map[string]string
+}
+
+// NewBlobProperties creates a new BlobProperties from an azblob.BlobGetPropertiesResponse
+func NewBlobProperties(azureProps *azblob.BlobGetPropertiesResponse) *BlobProperties {
+	lastModified := azureProps.LastModified()
+	return &BlobProperties{
+		LastModified: &lastModified,
+		Metadata:     azureProps.NewMetadata(),
+		Size:         uint64(azureProps.ContentLength()),
+	}
+}
diff --git a/backend/os/file.go b/backend/os/file.go
index a4c6543..4a0ea3d 100644
--- a/backend/os/file.go
+++ b/backend/os/file.go
@@ -198,8 +198,10 @@ func (f *File) MoveToFile(file vfs.File) error {
 	}
 
 	// do copy/delete move for non-native os moves
-	_, err := f.copyWithName(file.Name(), file.Location())
-	return err
+	if _, err := f.copyWithName(file.Name(), file.Location()); err != nil {
+		return err
+	}
+	return f.Delete()
 }
 
 // safeOsRename will attempt to do an os.Rename. If error is "invalid cross-device link" (where one OS file is on a
diff --git a/go.mod b/go.mod
index 4ab1446..39302c8 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,8 @@ go 1.12
 require (
 	cloud.google.com/go v0.74.0 // indirect
 	cloud.google.com/go/storage v1.10.0
+	github.com/Azure/azure-pipeline-go v0.2.3
+	github.com/Azure/azure-storage-blob-go v0.13.0
 	github.com/aws/aws-sdk-go v1.36.12
 	github.com/fatih/color v1.10.0
 	github.com/mitchellh/go-homedir v1.1.0
@@ -15,7 +17,6 @@ require (
 	golang.org/x/crypto v0.0.0-20201217014255-9d1352758620
 	golang.org/x/net v0.0.0-20201216054612-986b41b23924
 	golang.org/x/sys v0.0.0-20201218084310-7d0127a74742 // indirect
-	golang.org/x/tools v0.0.0-20201218024724-ae774e9781d2 // indirect
 	google.golang.org/api v0.36.0
 	google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d // indirect
 	gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect
diff --git a/go.sum b/go.sum
index 9d962d1..fedb293 100644
--- a/go.sum
+++ b/go.sum
@@ -36,10 +36,21 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
 cloud.google.com/go/storage v1.10.0 h1:STgFzyU5/8miMl0//zKh2aQeTyeaUH3WN9bSUiJ09bA=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
+github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
+github.com/Azure/azure-storage-blob-go v0.13.0 h1:lgWHvFh+UYBNVQLFHXkvul2f6yOPA9PIH82RTG2cSwc=
+github.com/Azure/azure-storage-blob-go v0.13.0/go.mod h1:pA9kNqtjUeQF2zOSu4s//nUdBD+e64lEuc4sVnuOfNs=
+github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
+github.com/Azure/go-autorest/autorest/adal v0.9.2/go.mod h1:/3SMAM86bP6wC9Ev35peQDUeqFZBMH07vvUOmg4z/fE=
+github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
+github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
+github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/aws/aws-sdk-go v1.36.12 h1:YJpKFEMbqEoo+incs5qMe61n1JH3o4O1IMkMexLzJG8=
 github.com/aws/aws-sdk-go v1.36.12/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
+github.com/c2fo/vfs v1.2.0 h1:zN4SunCJh88BBH+9klCjwYuJLLcZZ5nXukdWeptae94=
+github.com/c2fo/vfs v2.1.4+incompatible h1:vFvWpCkDESKR7DijQm0s0SvVfB8uOdsGE9e1NUdz9WI=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@@ -50,6 +61,7 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -117,6 +129,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
 github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
@@ -143,10 +157,13 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
 github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-ieproxy v0.0.1 h1:qiyop7gCflfhwCzGyeT0gro3sF9AIg9HU98JORTkqfI=
+github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
 github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/sftp v1.12.0 h1:/f3b24xrDhkhddlaobPe2JgBqfdt+gC/NYl0QY9IOuI=
@@ -235,6 +252,7 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -286,6 +304,7 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -302,6 +321,7 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -470,6 +490,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/vfssimple/vfssimple.go b/vfssimple/vfssimple.go
index 5cbf4ac..81261b5 100644
--- a/vfssimple/vfssimple.go
+++ b/vfssimple/vfssimple.go
@@ -10,6 +10,7 @@ import (
 	"github.com/c2fo/vfs/v5"
 	"github.com/c2fo/vfs/v5/backend"
 	_ "github.com/c2fo/vfs/v5/backend/all" // register all backends
+	"github.com/c2fo/vfs/v5/backend/azure"
 )
 
 // NewLocation is a convenience function that allows for instantiating a location based on a uri string. Any
@@ -52,6 +53,11 @@ func parseSupportedURI(uri string) (vfs.FileSystem, string, string, error) { //n
 
 	var fs vfs.FileSystem
 	for _, backendScheme := range backend.RegisteredBackends() {
+		// Azure
+		if azure.IsValidURI(u) {
+			host, path, err = azure.ParsePath(u.Path)
+		}
+
 		// Object-level backend
 		if strings.HasPrefix(uri, backendScheme) {
 			fs = backend.Backend(backendScheme)


Test patch:
diff --git a/backend/azure/client_integration_test.go b/backend/azure/client_integration_test.go
new file mode 100644
index 0000000..7998e6f
--- /dev/null
+++ b/backend/azure/client_integration_test.go
@@ -0,0 +1,234 @@
+//+build vfsintegration
+
+package azure
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/Azure/azure-storage-blob-go/azblob"
+	"github.com/stretchr/testify/suite"
+)
+
+type ClientTestSuite struct {
+	suite.Suite
+	testContainerURL azblob.ContainerURL
+	accountName      string
+	accountKey       string
+}
+
+func (s *ClientTestSuite) SetupSuite() {
+	s.accountName, s.accountKey = os.Getenv("AZURE_STORAGE_ACCOUNT"), os.Getenv("AZURE_STORAGE_ACCESS_KEY")
+
+	credential, err := azblob.NewSharedKeyCredential(s.accountName, s.accountKey)
+	if err != nil {
+		panic(err)
+	}
+
+	p := azblob.NewPipeline(credential, azblob.PipelineOptions{})
+	baseURL, err := url.Parse(fmt.Sprintf("https://%s.blob.core.windows.net", s.accountName))
+	s.NoError(err)
+	serviceURL := azblob.NewServiceURL(*baseURL, p)
+	s.testContainerURL = serviceURL.NewContainerURL("test-container")
+	_, err = s.testContainerURL.Create(context.Background(), azblob.Metadata{}, azblob.PublicAccessNone)
+	s.NoError(err)
+
+	// The create function claims to be synchronous but for some reason it does not exist for a little bit so
+	// we need to wait for it to be there.
+	_, err = s.testContainerURL.GetProperties(context.Background(), azblob.LeaseAccessConditions{})
+	for {
+		time.Sleep(2 * time.Second)
+		if err == nil || err.(azblob.StorageError).ServiceCode() != "BlobNotFound" {
+			break
+		}
+		_, err = s.testContainerURL.GetProperties(context.Background(), azblob.LeaseAccessConditions{})
+	}
+}
+
+func (s *ClientTestSuite) TearDownSuite() {
+	_, err := s.testContainerURL.Delete(context.Background(), azblob.ContainerAccessConditions{})
+	s.NoError(err)
+}
+
+func (s *ClientTestSuite) TestNewClient() {
+	client, err := NewClient("", "&")
+	s.Error(err, "AccountKey must be composed of base 64 decodable characters")
+	s.Nil(client, "The call to NewClient resulted in an error so the returned client should be nil")
+
+	client, err = NewClient(s.accountName, s.accountKey)
+	s.NoError(err, "Env variables (AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_ACCESS_KEY) should contain valid azure account credentials")
+	s.NotNil(client, "No error is expected so the client should be non-nil")
+	s.NotNil(client.pipeline, "pipeline should be non-nil")
+}
+
+func (s *ClientTestSuite) TestAllTheThings_FileWithNoPath() {
+	fs := NewFileSystem()
+	f, err := fs.NewFile("test-container", "/test.txt")
+	s.NoError(err)
+	client, err := fs.Client()
+	s.NoError(err, "Env variables (AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_ACCESS_KEY) should contain valid azure account credentials")
+
+	// Create the new file
+	err = client.Upload(f, strings.NewReader("Hello world!"))
+	s.NoError(err, "The file should be successfully uploaded to azure")
+
+	// make sure it exists
+	_, err = client.Properties(f.Location().URI(), f.Name())
+	s.NoError(err, "If the file exists no error should be returned")
+
+	// download it
+	reader, err := client.Download(f)
+	s.NoError(err)
+	dlContent, err := ioutil.ReadAll(reader)
+	err = reader.Close()
+	s.NoError(err)
+	s.NoError(err, "there should be no error reading the downloaded file")
+	s.Equal("Hello world!", string(dlContent))
+
+	// copy it
+	copyOf, err := fs.NewFile("test-container", "/copy_of_test.txt")
+	err = client.Copy(f, copyOf)
+	s.NoError(err, "Copy should succeed so there should be no error")
+	_, err = client.Properties(copyOf.Location().URI(), copyOf.Name())
+	s.NoError(err, "The copy should succeed so we should not get an error on the properties call")
+
+	// list the location
+	list, err := client.List(f.Location())
+	s.NoError(err)
+	s.Len(list, 2)
+	s.Equal("copy_of_test.txt", list[0])
+	s.Equal("test.txt", list[1])
+
+	// delete it
+	err = client.Delete(f)
+	s.NoError(err, "if the file was deleted no error should be returned")
+
+	// make sure it got deleted
+	_, err = client.Properties(f.Location().URI(), f.Name())
+	s.Error(err, "File should have been deleted so we should get an error")
+}
+
+func (s *ClientTestSuite) TestAllTheThings_FileWithPath() {
+	fs := NewFileSystem()
+	f, err := fs.NewFile("test-container", "/foo/bar/test.txt")
+	s.NoError(err)
+	client, err := fs.Client()
+	s.NoError(err, "Env variables (AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_ACCESS_KEY) should contain valid azure account credentials")
+
+	// create a new file
+	err = client.Upload(f, strings.NewReader("Hello world!"))
+	s.NoError(err, "The file should be successfully uploaded to azure")
+
+	// check to see if it exists
+	_, err = client.Properties(f.Location().(*Location).ContainerURL(), f.Path())
+	s.NoError(err, "If the file exists no error should be returned")
+
+	// download it
+	reader, err := client.Download(f)
+	s.NoError(err)
+	dlContent, err := ioutil.ReadAll(reader)
+	err = reader.Close()
+	s.NoError(err)
+	s.NoError(err, "there should be no error reading the downloaded file")
+	s.Equal("Hello world!", string(dlContent))
+
+	// list the location
+	list, err := client.List(f.Location())
+	s.NoError(err)
+	s.Len(list, 1)
+	s.Equal("foo/bar/test.txt", list[0])
+}
+
+func (s *ClientTestSuite) TestProperties() {
+	fs := NewFileSystem()
+	f, err := fs.NewFile("test-container", "/foo/bar/test.txt")
+	s.NoError(err)
+	client, err := fs.Client()
+	s.NoError(err, "Env variables (AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_ACCESS_KEY) should contain valid azure account credentials")
+
+	err = client.Upload(f, strings.NewReader("Hello world!"))
+	s.NoError(err, "The file should be successfully uploaded to azure so we shouldn't get an error")
+	props, err := client.Properties(f.Location().(*Location).ContainerURL(), f.Path())
+	s.NoError(err, "Tne file exists so we shouldn't get an error")
+	s.NotNil(props, "We should get a non-nil BlobProperties pointer back")
+	s.Greater(props.Size, uint64(0), "The size should be greater than zero")
+	s.NotNil(props.LastModified, "Should have a non-nil LastModified")
+}
+
+func (s *ClientTestSuite) TestProperties_Location() {
+	fs := NewFileSystem()
+	f, err := fs.NewFile("test-container", "/foo/bar/test.txt")
+	l, _ := fs.NewLocation("test-container", "/")
+	client, _ := fs.Client()
+
+	err = client.Upload(f, strings.NewReader("Hello world!"))
+	s.NoError(err, "The file should be successfully uploaded to azure so we shouldn't get an error")
+
+	props, err := client.Properties(l.URI(), "")
+	s.NoError(err)
+	s.Nil(props, "no props returned when calling properties on a location")
+}
+
+func (s *ClientTestSuite) TestProperties_NonExistentFile() {
+	fs := NewFileSystem()
+	f, err := fs.NewFile("test-container", "/nosuchfile.txt")
+	s.NoError(err)
+	client, err := fs.Client()
+	s.NoError(err, "Env variables (AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_ACCESS_KEY) should contain valid azure account credentials")
+
+	_, err = client.Properties(f.Location().URI(), f.Path())
+	s.Error(err, "Tne file does not exist so we expect an error")
+	s.Equal(404, err.(azblob.ResponseError).Response().StatusCode)
+}
+
+func (s *ClientTestSuite) TestDelete_NonExistentFile() {
+	fs := NewFileSystem()
+	f, err := fs.NewFile("test-container", "/nosuchfile.txt")
+	s.NoError(err)
+	client, err := fs.Client()
+	s.NoError(err, "Env variables (AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_ACCESS_KEY) should contain valid azure account credentials")
+
+	err = client.Delete(f)
+	s.Error(err, "Tne file does not exist so we expect an error")
+}
+
+func (s *ClientTestSuite) TestTouch_NonexistantContainer() {
+	fs := NewFileSystem()
+	f, err := fs.NewFile("nosuchcontainer", "/file.txt")
+	s.NoError(err)
+	client, err := fs.Client()
+	s.NoError(err, "Env variables (AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_ACCESS_KEY) should contain valid azure account credentials")
+
+	err = client.Upload(f, strings.NewReader(""))
+	s.Error(err, "The container doesn't exist so we should get an error")
+}
+
+func (s *ClientTestSuite) TestTouch_FileAlreadyExists() {
+	fs := NewFileSystem()
+	f, err := fs.NewFile("test-container", "/touch-test.txt")
+	s.NoError(err)
+	client, err := fs.Client()
+	s.NoError(err)
+
+	err = client.Upload(f, strings.NewReader("One fish, two fish, red fish, blue fish."))
+	s.NoError(err)
+	originalProps, err := client.Properties(f.Location().(*Location).ContainerURL(), f.Path())
+	s.NoError(err, "Should get properties back from azure with no error")
+
+	err = f.Touch()
+	s.NoError(err, "Should not receive an error when touching an existing file")
+	newProps, err := client.Properties(f.Location().(*Location).ContainerURL(), f.Path())
+	s.NoError(err)
+	s.NotNil(newProps, "New props should be non-nil")
+	s.True(newProps.LastModified.After(*originalProps.LastModified), "newProps.LastModified should be after originalProps.LastModified")
+}
+
+func TestAzureClient(t *testing.T) {
+	suite.Run(t, new(ClientTestSuite))
+}
diff --git a/backend/azure/fileSystem_test.go b/backend/azure/fileSystem_test.go
new file mode 100644
index 0000000..706c8f9
--- /dev/null
+++ b/backend/azure/fileSystem_test.go
@@ -0,0 +1,214 @@
+package azure
+
+import (
+	"errors"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+
+	"github.com/c2fo/vfs/v5"
+)
+
+type FileSystemTestSuite struct {
+	suite.Suite
+}
+
+func (s *FileSystemTestSuite) TestVFSFileSystemImplementor() {
+	fs := FileSystem{}
+	s.Implements((*vfs.FileSystem)(nil), &fs, "Does not implement the vfs.FileSystem interface")
+}
+
+func (s *FileSystemTestSuite) TestNewFile() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	file, err := fs.NewFile("", "")
+	s.Error(err, "volume and path are required")
+	s.Nil(file)
+	s.Equal("non-empty strings for container and path are required", err.Error())
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	file, err = fs.NewFile("temp", "")
+	s.Error(err, "volume and path are required")
+	s.Nil(file)
+	s.Equal("non-empty strings for container and path are required", err.Error())
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	file, err = fs.NewFile("", "/blah/blah.txt")
+	s.Error(err, "volume and path are required")
+	s.Nil(file)
+	s.Equal("non-empty strings for container and path are required", err.Error())
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	file, err = fs.NewFile("temp", "blah/blah.txt")
+	s.Error(err, "the path is invalid so we expect an error")
+	s.Equal("absolute file path is invalid - must include leading slash and may not include trailing slash", err.Error())
+	s.Nil(file, "Since an error was returned we expect a nil file to be returned")
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	file, err = fs.NewFile("temp", "/foo/bar/test.txt")
+	s.NoError(err, "The file path and volume are valid so we expect no errors")
+	s.NotNil(file, "No error was returned so we expect to get a non-nil file struct")
+	s.Equal("https://test-container.blob.core.windows.net/temp/foo/bar/test.txt", file.String())
+}
+
+func (s *FileSystemTestSuite) TestNewFile_NilReceiver() {
+	var fs *FileSystem
+	file, err := fs.NewFile("temp", "/foo/bar/test.txt")
+	s.Error(err, "the receiver pointer is nil so we whould receive an error")
+	s.Equal("azure.FileSystem receiver pointer must be non-nil", err.Error())
+	s.Nil(file, "Since there was an error we expect a nil file to be returned")
+}
+
+func (s *FileSystemTestSuite) TestNewLocation() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	loc, err := fs.NewLocation("", "")
+	s.Error(err, "volume and path are required")
+	s.Nil(loc, "volume and path are required")
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	loc, err = fs.NewLocation("", "/foo/bar/")
+	s.Error(err, "volume and path are required")
+	s.Nil(loc, "volume and path are required")
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	loc, err = fs.NewLocation("temp", "")
+	s.Error(err, "volume and path are required")
+	s.Nil(loc, "volume and path are required")
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	loc, err = fs.NewLocation("temp", "foo/bar/")
+	s.Error(err, "The path does not start with a slash and therefore not an absolute path so we expect an error")
+	s.Equal("absolute location path is invalid - must include leading and trailing slashes", err.Error())
+	s.Nil(loc, "Since an error was returned the location is nil")
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	loc, err = fs.NewLocation("temp", "/foo/bar")
+	s.Error(err, "The path does not end with a slash and therefore not an absolute path so we expect an error")
+	s.Equal("absolute location path is invalid - must include leading and trailing slashes", err.Error())
+	s.Nil(loc, "Since an error was returned the location is nil")
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	loc, err = fs.NewLocation("temp", "/foo/bar/")
+	s.NoError(err, "the path is valid so we expect no error")
+	s.NotNil(loc, "Since there was no error we expect a non-nil location")
+	s.Equal("https://test-container.blob.core.windows.net/temp/foo/bar/", loc.String())
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	loc, err = fs.NewLocation("temp", "/path/../to/")
+	s.NoError(err, "the path is valid so we expect no error")
+	s.NotNil(loc, "Since there was no error we expect a non-nil location")
+	s.Equal("https://test-container.blob.core.windows.net/temp/to/", loc.String())
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	loc, err = fs.NewLocation("temp", "/path/./to/")
+	s.NoError(err, "the path is valid so we expect no error")
+	s.NotNil(loc, "Since there was no error we expect a non-nil location")
+	s.Equal("https://test-container.blob.core.windows.net/temp/path/to/", loc.String())
+}
+
+func (s *FileSystemTestSuite) TestNewLocation_NilReceiver() {
+	var fs *FileSystem
+	loc, err := fs.NewLocation("temp", "/foo/bar/")
+	s.Error(err, "The receiver pointer on the function call is nill so we should get an error")
+	s.Equal("azure.FileSystem receiver pointer must be non-nil", err.Error())
+	s.Nil(loc, "The call returned an error so the location should be nil")
+}
+
+func (s *FileSystemTestSuite) TestName() {
+	fs := FileSystem{}
+	s.Equal("azure", fs.Name())
+}
+
+func (s *FileSystemTestSuite) TestScheme() {
+	fs := FileSystem{}
+	s.Equal("https", fs.Scheme())
+}
+
+func (s *FileSystemTestSuite) TestRetry() {
+	var doNothing = func() error {
+		return nil
+	}
+
+	var errorRetry = func(wrapped func() error) error {
+		return errors.New("i always error")
+	}
+
+	fs := FileSystem{}
+	retryFn := fs.Retry()
+	s.NotNil(retryFn)
+	err := retryFn(doNothing)
+	s.NoError(err, "The default retry function just calls the passed func")
+
+	fs = *NewFileSystem().WithOptions(Options{RetryFunc: errorRetry})
+	retryFn = fs.Retry()
+	err = retryFn(doNothing)
+	s.Error(err, "This implementation should use the retry function from the options which always errors")
+	s.Equal("i always error", err.Error())
+}
+
+func (s *FileSystemTestSuite) TestNewFileSystem() {
+	fs := NewFileSystem()
+	s.NotNil(fs, "Should return a non-nil pointer to the new file system")
+}
+
+func (s *FileSystemTestSuite) TestWithOptions() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "foo-account"})
+	s.NotNil(fs, "Should return the modified FileSystem struct")
+	s.Equal("foo-account", fs.options.AccountName)
+
+	fs = NewFileSystem().WithOptions("Not Azure Options...")
+	s.Equal("", fs.options.AccountName)
+}
+
+func (s *FileSystemTestSuite) TestClient() {
+	fs := NewFileSystem().WithClient(&MockAzureClient{})
+	s.NotNil(fs.Client())
+
+	fs = NewFileSystem()
+	s.NotNil(fs.Client())
+}
+
+func (s *FileSystemTestSuite) TestParsePath() {
+	uri := "https://my-account.blob.core.windows.net/my_container/foo/bar/baz/"
+	u, _ := url.Parse(uri)
+	volume, path, err := ParsePath(u.Path)
+	s.NoError(err, "Path is valid so we should not get an error")
+	s.Equal("my_container", volume)
+	s.Equal("/foo/bar/baz/", path)
+
+	uri = "https://my-account.blob.core.windows.net/my_container/"
+	u, _ = url.Parse(uri)
+	volume, path, err = ParsePath(u.Path)
+	s.NoError(err, "Path is valid so we should not get an error")
+	s.Equal("my_container", volume)
+	s.Equal("/", path)
+
+	uri = "https://my-account.blob.core.windows.net/"
+	u, _ = url.Parse(uri)
+	volume, path, err = ParsePath(u.Path)
+	s.Error(err, "a container is required so we should get an error")
+	s.Equal("", volume, "we got an error so volume should be empty")
+	s.Equal("", path, "we got an error so path should be empty")
+}
+
+func (s *FileSystemTestSuite) TestIsValidURI() {
+	uri := "https://my-account.blob.core.windows.net/my_container/foo/bar/baz/"
+	u, _ := url.Parse(uri)
+	s.True(IsValidURI(u), "the uri should be recognized as an Azure uri")
+
+	uri = "foo://my-account.blob.core.windows.net/my_container/foo/bar/baz/"
+	u, _ = url.Parse(uri)
+	s.False(IsValidURI(u), "the uri has an invalid scheme so it is not an Azure uri")
+
+	uri = "https://yadda.yadda.yadda/my_container/foo/bar/baz/"
+	u, _ = url.Parse(uri)
+	s.False(IsValidURI(u), "the host does not match .*.blob.core.windows.net so it is not an Azure uri")
+
+	uri = "foo://yadda.yadda.yadda/my_container/foo/bar/baz/"
+	u, _ = url.Parse(uri)
+	s.False(IsValidURI(u), "nothing ab out this uri is right...")
+}
+
+func TestAzureFileSystem(t *testing.T) {
+	suite.Run(t, new(FileSystemTestSuite))
+}
diff --git a/backend/azure/file_test.go b/backend/azure/file_test.go
new file mode 100644
index 0000000..791ba1d
--- /dev/null
+++ b/backend/azure/file_test.go
@@ -0,0 +1,357 @@
+package azure
+
+import (
+	"errors"
+	"io"
+	"io/ioutil"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/suite"
+
+	"github.com/c2fo/vfs/v5"
+)
+
+type FileTestSuite struct {
+	suite.Suite
+}
+
+func (s *FileTestSuite) TestVFSFileImplementor() {
+	f := File{}
+	s.Implements((*vfs.File)(nil), &f, "Does not implement the vfs.File interface")
+}
+
+func (s *FileTestSuite) TestClose() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+	f, _ := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(f.Close())
+}
+
+func (s *FileTestSuite) TestClose_FlushTempFile() {
+	client := MockAzureClient{PropertiesError: MockStorageError{}}
+	fs := NewFileSystem().WithClient(&client)
+	f, _ := fs.NewFile("test-container", "/foo.txt")
+
+	_, _ = f.Write([]byte("Hello, World!"))
+	s.NoError(f.Close())
+}
+
+func (s *FileTestSuite) TestRead() {
+	client := MockAzureClient{ExpectedResult: ioutil.NopCloser(strings.NewReader("Hello World!"))}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The file should exist so no error should be returned")
+	contents := make([]byte, 12)
+	n, err := f.Read(contents)
+	s.NoError(err)
+	s.Equal(12, n)
+	s.Equal("Hello World!", string(contents))
+}
+
+func (s *FileTestSuite) TestSeek() {
+	client := MockAzureClient{ExpectedResult: ioutil.NopCloser(strings.NewReader("Hello World!"))}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The file should exist so no error should be returned")
+	newOffset, err := f.Seek(6, io.SeekStart)
+	s.NoError(err)
+	s.Equal(int64(6), newOffset)
+	contents := make([]byte, 6)
+	n, err := f.Read(contents)
+	s.NoError(err)
+	s.Equal(6, n)
+	s.Equal("World!", string(contents))
+}
+
+func (s *FileTestSuite) TestWrite() {
+	client := MockAzureClient{ExpectedResult: ioutil.NopCloser(strings.NewReader("Hello World!"))}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NotNil(f)
+	s.NoError(err)
+	n, err := f.Write([]byte(" Aaaaand, Goodbye!"))
+	s.NoError(err)
+	s.Equal(18, n)
+}
+
+func (s *FileTestSuite) TestString() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-account"})
+	l, _ := fs.NewLocation("temp", "/foo/bar/")
+	f, _ := l.NewFile("blah.txt")
+	s.Equal("https://test-account.blob.core.windows.net/temp/foo/bar/blah.txt", f.String())
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-account"})
+	l, _ = fs.NewLocation("folder", "/blah/")
+	f, _ = l.NewFile("file.txt")
+	s.Equal("https://test-account.blob.core.windows.net/folder/blah/file.txt", f.String())
+}
+
+func (s *FileTestSuite) TestExists() {
+	client := MockAzureClient{PropertiesResult: &BlobProperties{}}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The file should exist so no error should be returned")
+	exists, err := f.Exists()
+	s.NoError(err)
+	s.True(exists)
+}
+
+func (s *FileTestSuite) TestExists_NonExistantFile() {
+	client := MockAzureClient{PropertiesError: MockStorageError{}}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The path is valid so no error should be returned")
+	exists, err := f.Exists()
+	s.NoError(err, "no error is returned when  the file does not exist")
+	s.False(exists)
+}
+
+func (s *FileTestSuite) TestLocation() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-account"})
+	f, _ := fs.NewFile("test-container", "/file.txt")
+	l := f.Location()
+	s.NotNil(l)
+	s.Equal("https://test-account.blob.core.windows.net/test-container/", l.URI())
+}
+
+func (s *FileTestSuite) TestCopyToLocation() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+	source, _ := fs.NewFile("test-container", "/foo.txt")
+	targetLoc, _ := fs.NewLocation("test-container", "/new/folder/")
+	copiedFile, err := source.CopyToLocation(targetLoc)
+	s.NoError(err)
+	s.NotNil(copiedFile)
+	s.Equal("/new/folder/foo.txt", copiedFile.Path())
+}
+
+func (s *FileTestSuite) TestCopyToFile() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+	source, _ := fs.NewFile("test-container", "/foo.txt")
+	target, _ := fs.NewFile("test-container", "/bar.txt")
+
+	err := source.CopyToFile(target)
+	s.NoError(err)
+}
+
+func (s *FileTestSuite) TestMoveToLocation() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+	source, _ := fs.NewFile("test-container", "/foo.txt")
+	target, _ := fs.NewLocation("test-container", "/new/folder/")
+
+	movedFile, err := source.MoveToLocation(target)
+	s.NoError(err)
+	s.NotNil(movedFile)
+	s.Equal("/new/folder/foo.txt", movedFile.Path())
+}
+
+func (s *FileTestSuite) TestMoveToFile() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+	source, _ := fs.NewFile("test-container", "/foo.txt")
+	target, _ := fs.NewFile("test-container", "/bar.txt")
+	err := source.MoveToFile(target)
+	s.NoError(err)
+}
+
+func (s *FileTestSuite) TestDelete() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The path is valid so no error should be returned")
+	s.NoError(f.Delete(), "The delete should succeed so there should be no error")
+}
+
+func (s *FileTestSuite) TestDelete_NonExistantFile() {
+	client := MockAzureClient{ExpectedError: errors.New("i always error")}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The path is valid so no error should be returned")
+	err = f.Delete()
+	s.Error(err, "If the file does not exist we get an error")
+}
+
+func (s *FileTestSuite) TestLastModified() {
+	now := time.Now()
+	client := MockAzureClient{PropertiesResult: &BlobProperties{LastModified: &now}}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The path is valid so no error should be returned")
+	t, err := f.LastModified()
+	s.NoError(err)
+	s.NotNil(t)
+}
+
+func (s *FileTestSuite) TestSize() {
+	client := MockAzureClient{PropertiesResult: &BlobProperties{Size: 5}}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The path is valid so no error should be returned")
+	size, err := f.Size()
+	s.NoError(err)
+	s.Equal(uint64(5), size, "The size should be 5")
+}
+
+func (s *FileTestSuite) TestSize_NonExistantFile() {
+	client := MockAzureClient{PropertiesError: errors.New("i always error")}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The path is valid so no error should be returned")
+	size, err := f.Size()
+	s.Error(err, "If the file does not exist we get an error")
+	s.Equal(uint64(0), size, "the file does not exist so the size is 0")
+}
+
+func (s *FileTestSuite) TestPath() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-account"})
+	f, _ := fs.NewFile("test-container", "/foo/bar/blah.txt")
+	s.Equal("/foo/bar/blah.txt", f.Path())
+
+	f, _ = fs.NewFile("test-container", "/test/file.txt")
+	s.Equal("/test/file.txt", f.Path())
+}
+
+func (s *FileTestSuite) TestName() {
+	f := File{}
+	s.Equal(".", f.Name())
+
+	f = File{name: "foo.txt"}
+	s.Equal("foo.txt", f.Name())
+}
+
+func (s *FileTestSuite) TestTouch() {
+	client := MockAzureClient{ExpectedResult: &BlobProperties{}, PropertiesError: MockStorageError{}}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The path is valid so no error should be returned")
+	s.NoError(f.Touch())
+}
+
+func (s *FileTestSuite) TestTouch_NonexistantContainer() {
+	client := MockAzureClient{ExpectedError: errors.New("i always error")}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("nosuchcontainer", "/foo.txt")
+	s.NoError(err, "The path is valid so no error should be returned")
+	s.Error(f.Touch(), "The container does not exist so creating the new file should error")
+}
+
+func (s *FileTestSuite) TestURI() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	f, _ := fs.NewFile("temp", "/foo/bar/blah.txt")
+	s.Equal("https://test-container.blob.core.windows.net/temp/foo/bar/blah.txt", f.URI())
+
+	fs = NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	f, _ = fs.NewFile("folder", "/blah/file.txt")
+	s.Equal("https://test-container.blob.core.windows.net/folder/blah/file.txt", f.URI())
+}
+
+func (s *FileTestSuite) TestCheckTempFile() {
+	client := MockAzureClient{ExpectedResult: ioutil.NopCloser(strings.NewReader("Hello World!"))}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The file should exist so no error should be returned")
+
+	azureFile, ok := f.(*File)
+	s.True(ok, "Type assertion should succeed so we expect ok to be true")
+	s.NotNil(azureFile)
+
+	s.Nil(azureFile.tempFile, "No calls to checkTempFile have occurred so we expect tempFile to be nil")
+	err = azureFile.checkTempFile()
+	s.NoError(err, "Check temp file should create a local temp file so no error is expected")
+	s.NotNil(azureFile.tempFile, "After the call to checkTempFile we should have a non-nil tempFile")
+
+	contents, err := ioutil.ReadAll(azureFile.tempFile)
+	s.NoError(err, "No error should occur while reading the tempFile")
+	s.Equal("Hello World!", string(contents))
+}
+
+func (s *FileTestSuite) TestCheckTempFile_FileDoesNotExist() {
+	client := MockAzureClient{PropertiesError: MockStorageError{}}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The file should exist so no error should be returned")
+
+	azureFile, ok := f.(*File)
+	s.True(ok, "Type assertion should succeed so we expect ok to be true")
+	s.NotNil(azureFile)
+
+	s.Nil(azureFile.tempFile, "No calls to checkTempFile have occurred so we expect tempFile to be nil")
+	err = azureFile.checkTempFile()
+	s.NoError(err, "Check temp file should create a local temp file so no error is expected")
+	s.NotNil(azureFile.tempFile, "After the call to checkTempFile we should have a non-nil tempFile")
+
+	contents, err := ioutil.ReadAll(azureFile.tempFile)
+	s.NoError(err, "No error should occur while reading the tempFile")
+	s.Equal("", string(contents))
+}
+
+func (s *FileTestSuite) TestCheckTempFile_DownloadError() {
+	client := MockAzureClient{ExpectedError: errors.New("i always error")}
+	fs := NewFileSystem().WithClient(&client)
+
+	f, err := fs.NewFile("test-container", "/foo.txt")
+	s.NoError(err, "The file should exist so no error should be returned")
+
+	azureFile, ok := f.(*File)
+	s.True(ok, "Type assertion should succeed so we expect ok to be true")
+	s.NotNil(azureFile)
+
+	s.Nil(azureFile.tempFile, "No calls to checkTempFile have occurred so we expect tempFile to be nil")
+	err = azureFile.checkTempFile()
+	s.Error(err, "The call to client.Download() errors so we expect to get an error")
+}
+
+func (s *FileTestSuite) TestIsSameAuth_SameAcctKey() {
+	fs := NewFileSystem().WithOptions(Options{
+		AccountName: "foo",
+		AccountKey:  "bar",
+	})
+
+	f1, _ := fs.NewFile("test-container", "/foo.txt")
+	f2, _ := fs.NewFile("test-container", "/bar.txt")
+	sourceFile := f1.(*File)
+	targetFile := f2.(*File)
+
+	s.True(sourceFile.isSameAuth(targetFile), "Files were created with the same options so same auth should be true")
+}
+
+func (s *FileTestSuite) TestIsSameAuth_DifferentAcctKey() {
+	sourceFs := NewFileSystem().WithOptions(Options{
+		AccountName: "foo",
+		AccountKey:  "bar",
+	})
+
+	targetFs := NewFileSystem().WithOptions(Options{
+		AccountName: "blah",
+		AccountKey:  "blahblah",
+	})
+
+	src, _ := sourceFs.NewFile("test-container", "/foo.txt")
+	tgt, _ := targetFs.NewFile("test-container", "/bar.txt")
+	sourceFile := src.(*File)
+	targetFile := tgt.(*File)
+
+	s.False(sourceFile.isSameAuth(targetFile), "Files were created with different account keys so same auth should be false")
+}
+
+func TestAzureFile(t *testing.T) {
+	suite.Run(t, new(FileTestSuite))
+}
diff --git a/backend/azure/location_test.go b/backend/azure/location_test.go
new file mode 100644
index 0000000..d34a8d8
--- /dev/null
+++ b/backend/azure/location_test.go
@@ -0,0 +1,260 @@
+package azure
+
+import (
+	"errors"
+	"regexp"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+
+	"github.com/c2fo/vfs/v5"
+)
+
+type LocationTestSuite struct {
+	suite.Suite
+}
+
+func (s *LocationTestSuite) TestVFSLocationImplementor() {
+	l := Location{}
+	s.Implements((*vfs.Location)(nil), &l, "Does not implement the vfs.Location interface")
+}
+
+func (s *LocationTestSuite) TestString() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-account"})
+	l, _ := fs.NewLocation("test-container", "/")
+	s.Equal("https://test-account.blob.core.windows.net/test-container/", l.String())
+
+	err := l.ChangeDir("foo/bar/baz/")
+	s.NoError(err, "Should change directories successfully")
+	s.Equal("https://test-account.blob.core.windows.net/test-container/foo/bar/baz/", l.String())
+
+	l, _ = fs.NewLocation("temp", "/foo/bar/baz/")
+	s.Equal("https://test-account.blob.core.windows.net/temp/foo/bar/baz/", l.String())
+}
+
+func (s *LocationTestSuite) TestList() {
+	client := MockAzureClient{ExpectedResult: []string{"file1.txt", "file2.txt"}}
+	fs := NewFileSystem().WithClient(&client)
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	listing, err := l.List()
+	s.NoError(err)
+	s.Len(listing, 2)
+}
+
+func (s *LocationTestSuite) TestListByPrefix() {
+	client := MockAzureClient{ExpectedResult: []string{"file1.txt", "file2.txt", "foo.txt"}}
+	fs := NewFileSystem().WithClient(&client)
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	listing, err := l.ListByPrefix("file")
+	s.NoError(err)
+	s.Len(listing, 2)
+	s.Equal("file1.txt", listing[0])
+	s.Equal("file2.txt", listing[1])
+}
+
+func (s *LocationTestSuite) TestListByRegex() {
+	client := MockAzureClient{ExpectedResult: []string{"file1.txt", "file2.txt", "foo.txt"}}
+	fs := NewFileSystem().WithClient(&client)
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	regex := regexp.MustCompile("file")
+	listing, err := l.ListByRegex(regex)
+	s.NoError(err)
+	s.Len(listing, 2, "expect the 2 files with the substring 'file' to be returned")
+	s.Equal("file1.txt", listing[0])
+	s.Equal("file2.txt", listing[1])
+}
+
+func (s *LocationTestSuite) TestVolume() {
+	l := Location{container: "test-container"}
+	s.Equal("test-container", l.Volume())
+
+	l = Location{container: "another-container"}
+	s.Equal("another-container", l.Volume())
+}
+
+func (s *LocationTestSuite) TestPath() {
+	l := Location{path: "/foo/bar/"}
+	s.Equal("/foo/bar/", l.Path())
+
+	l = Location{path: "/foo/bar/baz/"}
+	s.Equal("/foo/bar/baz/", l.Path())
+
+	l = Location{path: "/foo"}
+	s.Equal("/foo/", l.Path())
+
+	l = Location{path: "foo/"}
+	s.Equal("/foo/", l.Path())
+
+	l = Location{path: "foo"}
+	s.Equal("/foo/", l.Path())
+
+	l = Location{}
+	s.Equal("/", l.Path())
+}
+
+func (s *LocationTestSuite) TestExists() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	exists, err := l.Exists()
+	s.NoError(err)
+	s.True(exists)
+}
+
+func (s *LocationTestSuite) TestExists_NonExistentFile() {
+	client := MockAzureClient{PropertiesError: errors.New("no such file")}
+	fs := NewFileSystem().WithClient(&client)
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	exists, err := l.Exists()
+	s.NoError(err)
+	s.False(exists)
+}
+
+func (s *LocationTestSuite) TestNewLocation() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	nl, err := l.NewLocation("")
+	s.Error(err, "An empty relative path does not end with a slash and therefore is not a valid relative path so this should return an error")
+	s.Nil(nl, "There were no errors encountered when creating the new location so the returned interface type should t non-nil")
+
+	nl, err = l.NewLocation("path/to/")
+	s.NoError(err, "The new path was a valid relative path so there should be no error")
+	s.NotNil(nl, "There were no errors encountered when creating the new location so the returned interface type should t non-nil")
+	s.NotNil(nl.(*Location).fileSystem, "file system must be non-nil")
+	s.Equal("/some/folder/path/to/", nl.Path())
+
+	nl, err = l.NewLocation("path/../to/")
+	s.NoError(err, "The new path was a valid relative path so there should be no error")
+	s.NotNil(nl, "There were no errors encountered when creating the new location so the returned interface type should t non-nil")
+	s.NotNil(nl.(*Location).fileSystem, "file system must be non-nil")
+	s.Equal("/some/folder/to/", nl.Path())
+
+	nl, err = l.NewLocation("/test-container/")
+	s.Error(err, "The new path begins with a slash and therefore is not a valid relative path so this should return an error")
+	s.Nil(nl, "There was an error creating the new location so a nil pointer should be returned")
+
+	nl, err = l.NewLocation("test-container")
+	s.Error(err, "The new path does not end with a slash and therefore is not a valid relative path so this should return an error")
+	s.Nil(nl, "There was an error creating the new location so a nil pointer should be returned")
+}
+
+func (s *LocationTestSuite) TestNewLocation_NilReceiver() {
+	var l *Location
+	nl, err := l.NewLocation("test-container/")
+	s.Error(err, "The receiver for NewLocaiton must be non-nil so we expect an error")
+	s.Equal("azure.Location receiver pointer must be non-nil", err.Error())
+	s.Nil(nl, "An error was returned so we expect a nil location to be returned")
+}
+
+func (s *LocationTestSuite) TestChangeDir() {
+	l := Location{}
+	err := l.ChangeDir("test-container/")
+	s.NoError(err)
+	s.Equal("/test-container/", l.Path())
+
+	err = l.ChangeDir("path/../to/./new/dir/")
+	s.NoError(err)
+	s.Equal("/test-container/to/new/dir/", l.Path())
+
+	l = Location{}
+	err = l.ChangeDir("/test-container/")
+	s.Error(err, "The path begins with a slash and therefore is not a relative path so this should return an error")
+	s.Equal("relative location path is invalid - may not include leading slash but must include trailing slash", err.Error())
+
+	l = Location{}
+	err = l.ChangeDir("test-container")
+	s.Error(err, "The path does not end with a slash and therefore is not a relative path so this should return an error")
+	s.Equal("relative location path is invalid - may not include leading slash but must include trailing slash", err.Error())
+
+	l = Location{}
+	err = l.ChangeDir("")
+	s.Error(err, "An empty relative path does not end with a slash and therefore is not a valid relative path so this should return an error")
+	s.Equal("relative location path is invalid - may not include leading slash but must include trailing slash", err.Error())
+}
+
+func (s *LocationTestSuite) TestChangeDir_NilReceiver() {
+	var l *Location
+	s.Nil(l)
+	err := l.ChangeDir("")
+	s.Error(err)
+	s.Equal("azure.Location receiver pointer must be non-nil", err.Error())
+}
+
+func (s *LocationTestSuite) TestFileSystem() {
+	fs := NewFileSystem()
+	l := Location{fileSystem: fs}
+	s.NotNil(l.FileSystem())
+}
+
+func (s *LocationTestSuite) TestNewFile() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-container"})
+	l, _ := fs.NewLocation("test-container", "/folder/")
+
+	f, err := l.NewFile("")
+	s.Error(err, "Empty string is not a valid relative file path so we expect an error")
+	s.Equal("relative file path is invalid - may not include leading or trailing slashes", err.Error())
+	s.Nil(f, "Since the call to NewFile resulted in an error we expect a nil pointer")
+
+	f, err = l.NewFile("/foo/bar.txt")
+	s.Error(err, "The file path begins with a slash therefore it is not a valid relative file path so we expect an error")
+	s.Equal("relative file path is invalid - may not include leading or trailing slashes", err.Error())
+	s.Nil(f, "Since the call to NewFile resulted in an error we expect a nil pointer")
+
+	f, err = l.NewFile("foo/bar/")
+	s.Error(err, "The file path ends with a slash therefore it is not a valid relative file path so we expect an error")
+	s.Equal("relative file path is invalid - may not include leading or trailing slashes", err.Error())
+	s.Nil(f, "Since the call to NewFile resulted in an error we expect a nil pointer")
+
+	f, err = l.NewFile("foo/bar.txt")
+	s.NoError(err, "The file path is valid so we expect no error to be returned")
+	s.NotNil(f, "The call to NewFile did not return an error so we expect a non-nil pointer to a file struct")
+	s.Equal("/folder/foo/bar.txt", f.Path())
+	s.Equal("https://test-container.blob.core.windows.net/test-container/folder/foo/bar.txt", f.URI())
+}
+
+func (s *LocationTestSuite) TestNewFile_NilReceiver() {
+	var l *Location
+	f, err := l.NewFile("foo/bar.txt")
+	s.Error(err, "Can't create a new file from a nil location so we expect an error")
+	s.Equal("azure.Location receiver pointer must be non-nil", err.Error())
+	s.Nil(f, "the call to NewFile returned an error so we expect a nil pointer")
+}
+
+func (s *LocationTestSuite) TestDeleteFile() {
+	client := MockAzureClient{}
+	fs := NewFileSystem().WithClient(&client)
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	s.NoError(l.DeleteFile("clever_file.txt"), "the file exists so we do not expect an error")
+}
+
+func (s *LocationTestSuite) TestDeleteFile_DoesNotExist() {
+	client := MockAzureClient{ExpectedError: errors.New("no such file")}
+	fs := NewFileSystem().WithClient(&client)
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	s.Error(l.DeleteFile("nosuchfile.txt"), "the file does not exist so we expect an error")
+}
+
+func (s *LocationTestSuite) TestURI() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-account"})
+	l, _ := fs.NewLocation("test-container", "/")
+	s.Equal("https://test-account.blob.core.windows.net/test-container/", l.URI())
+
+	err := l.ChangeDir("foo/bar/baz/")
+	s.NoError(err, "Should change directories successfully")
+	s.Equal("https://test-account.blob.core.windows.net/test-container/foo/bar/baz/", l.URI())
+
+	vfsLoc, err := fs.NewLocation("temp", "/foo/bar/baz/")
+	s.NoError(err, "Path is valid so we expect no errors")
+	s.Equal("https://test-account.blob.core.windows.net/temp/foo/bar/baz/", vfsLoc.URI())
+}
+
+func (s *LocationTestSuite) TestContainerURL() {
+	fs := NewFileSystem().WithOptions(Options{AccountName: "test-account"})
+	l, _ := fs.NewLocation("test-container", "/some/folder/")
+	s.Equal("https://test-account.blob.core.windows.net/test-container/", l.(*Location).ContainerURL())
+}
+
+func TestAzureLocation(t *testing.T) {
+	suite.Run(t, new(LocationTestSuite))
+}
diff --git a/backend/testsuite/backend_integration_test.go b/backend/testsuite/backend_integration_test.go
index 3ff35eb..8da9538 100644
--- a/backend/testsuite/backend_integration_test.go
+++ b/backend/testsuite/backend_integration_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/stretchr/testify/suite"
 
 	"github.com/c2fo/vfs/v5"
+	"github.com/c2fo/vfs/v5/backend/azure"
 	"github.com/c2fo/vfs/v5/backend/gs"
 	"github.com/c2fo/vfs/v5/backend/mem"
 	_os "github.com/c2fo/vfs/v5/backend/os"
@@ -70,6 +71,19 @@ func copyGSLocation(loc vfs.Location) vfs.Location {
 	return &cp
 }
 
+func copyAzureLocation(loc vfs.Location) vfs.Location {
+	cp := *loc.(*azure.Location)
+	return &cp
+}
+
+func buildExpectedURI(fs vfs.FileSystem, volume, path string) string {
+	if fs.Name() == "azure" {
+		azFs := fs.(*azure.FileSystem)
+		return fmt.Sprintf("%s://%s/%s%s", fs.Scheme(), azFs.Host(), volume, path)
+	}
+	return fmt.Sprintf("%s://%s%s", fs.Scheme(), volume, path)
+}
+
 func (s *vfsTestSuite) SetupSuite() {
 	locs := os.Getenv("VFS_INTEGRATION_LOCATIONS")
 	s.testLocations = make(map[string]vfs.Location)
@@ -87,6 +101,8 @@ func (s *vfsTestSuite) SetupSuite() {
 			s.testLocations[l.FileSystem().Scheme()] = copyGSLocation(l)
 		case "mem":
 			s.testLocations[l.FileSystem().Scheme()] = copyMemLocation(l)
+		case "https":
+			s.testLocations[l.FileSystem().Scheme()] = copyAzureLocation(l)
 		default:
 			panic(fmt.Sprintf("unknown scheme: %s", l.FileSystem().Scheme()))
 		}
@@ -135,10 +151,10 @@ func (s *vfsTestSuite) FileSystem(baseLoc vfs.Location) {
 		file, err := fs.NewFile(baseLoc.Volume(), name)
 		if validates {
 			s.NoError(err, "there should be no error")
-			expected := fmt.Sprintf("%s://%s%s", fs.Scheme(), baseLoc.Volume(), path.Clean(name))
+			expected := buildExpectedURI(fs, baseLoc.Volume(), path.Clean(name))
 			s.Equal(expected, file.URI(), "uri's should match")
 		} else {
-			s.Error(err, "should have validation error for scheme and name: %s : %s", fs.Scheme(), name)
+			s.Error(err, "should have validation error for scheme[%s] and name[%s]", fs.Scheme(), name)
 		}
 	}
 
@@ -163,11 +179,11 @@ func (s *vfsTestSuite) FileSystem(baseLoc vfs.Location) {
 		loc, err := fs.NewLocation(baseLoc.Volume(), name)
 		if validates {
 			s.NoError(err, "there should be no error")
-			expected := fmt.Sprintf("%s://%s%s", fs.Scheme(), baseLoc.Volume(), utils.EnsureTrailingSlash(path.Clean(name)))
+			expected := buildExpectedURI(fs, baseLoc.Volume(), utils.EnsureTrailingSlash(path.Clean(name)))
 			s.Equal(expected, loc.URI(), "uri's should match")
 
 		} else {
-			s.Error(err, "should have validation error for scheme and name: %s : %s", fs.Scheme(), name)
+			s.Error(err, "should have validation error for scheme[%s] and name[%s]", fs.Scheme(), name)
 		}
 	}
 }
@@ -215,7 +231,7 @@ func (s *vfsTestSuite) Location(baseLoc vfs.Location) {
 		loc, err := srcLoc.NewLocation(name)
 		if validates {
 			s.NoError(err, "there should be no error")
-			expected := fmt.Sprintf("%s://%s%s", srcLoc.FileSystem().Scheme(), baseLoc.Volume(), utils.EnsureTrailingSlash(path.Clean(path.Join(srcLoc.Path(), name))))
+			expected := buildExpectedURI(srcLoc.FileSystem(), baseLoc.Volume(), utils.EnsureTrailingSlash(path.Clean(path.Join(srcLoc.Path(), name))))
 			s.Equal(expected, loc.URI(), "uri's should match")
 
 		} else {
@@ -247,7 +263,7 @@ func (s *vfsTestSuite) Location(baseLoc vfs.Location) {
 		file, err := srcLoc.NewFile(name)
 		if validates {
 			s.NoError(err, "there should be no error")
-			expected := fmt.Sprintf("%s://%s%s", srcLoc.FileSystem().Scheme(), srcLoc.Volume(), path.Clean(path.Join(srcLoc.Path(), name)))
+			expected := buildExpectedURI(srcLoc.FileSystem(), srcLoc.Volume(), path.Clean(path.Join(srcLoc.Path(), name)))
 			s.Equal(expected, file.URI(), "uri's should match")
 		} else {
 			s.Error(err, "should have validation error for scheme and name: %s : +%s+", srcLoc.FileSystem().Scheme(), name)
@@ -283,7 +299,7 @@ func (s *vfsTestSuite) Location(baseLoc vfs.Location) {
 	//
 	// URI's for locations must always end with a separator character.
 	s.True(strings.HasSuffix(cdTestLoc.URI(), "locTestSrc/chdirTest/l1dir1/l2dir2/"), "should end with dot dirs resolved")
-	prefix := fmt.Sprintf("%s://%s%s", cdTestLoc.FileSystem().Scheme(), cdTestLoc.Volume(), "/")
+	prefix := fmt.Sprintf("%s://", cdTestLoc.FileSystem().Scheme())
 	s.True(strings.HasPrefix(cdTestLoc.URI(), prefix), "should start with schema and abs slash")
 
 	/* Exists returns boolean if the location exists on the file system. Returns an error if any.