Repository: C2FO/vfs
Pull request #173 (2024-02-26 01:32:00)

Update GS backend to comply with new io integration test suite. fixes…
… #172

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO) Report
Attention: Patch coverage is `44.39655%` with `129 lines` in your changes are missing coverage. Please review.
> Project coverage is 21.25%. Comparing base [(`cb9af63`)](https://app.codecov.io/gh/C2FO/vfs/commit/cb9af636d05ecaccfe27b165380a63ca6a84599f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO) to head [(`fd80b2a`)](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO).

| [Files](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO) | Patch % | Lines |
|---|---|---|
| [backend/gs/file.go](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO#diff-YmFja2VuZC9ncy9maWxlLmdv) | 41.75% | [87 Missing and 26 partials :warning: ](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO) |
| [utils/errors.go](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO#diff-dXRpbHMvZXJyb3JzLmdv) | 0.00% | [8 Missing :warning: ](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO) |
| [backend/gs/objectHandleWrapper.go](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO#diff-YmFja2VuZC9ncy9vYmplY3RIYW5kbGVXcmFwcGVyLmdv) | 53.84% | [5 Missing and 1 partial :warning: ](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO) |
| [backend/sftp/options.go](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO#diff-YmFja2VuZC9zZnRwL29wdGlvbnMuZ28=) | 0.00% | [0 Missing and 2 partials :warning: ](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO) |

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #173      +/-   ##
==========================================
+ Coverage   21.20%   21.25%   +0.04%     
==========================================
  Files          58       59       +1     
  Lines       19018    19173     +155     
==========================================
+ Hits         4033     4075      +42     
- Misses      14539    14636      +97     
- Partials      446      462      +16     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/C2FO/vfs/pull/173?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=C2FO).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54ee3da..efddf59 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 ## [Unreleased]
+### Added
+- Update GS backend to comply with new io integration test suite. #172
 
 ## [6.12.1] - 2024-02-23
 ### Fixed
diff --git a/backend/gs/file.go b/backend/gs/file.go
index 0a32ced..dbf08d0 100644
--- a/backend/gs/file.go
+++ b/backend/gs/file.go
@@ -1,8 +1,8 @@
 package gs
 
 import (
-	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -25,84 +25,312 @@ const (
 
 // File implements vfs.File interface for GS fs.
 type File struct {
-	fileSystem  *FileSystem
-	bucket      string
-	key         string
-	tempFile    *os.File
-	writeBuffer *bytes.Buffer
+	fileSystem *FileSystem
+	bucket     string
+	key        string
+
+	// seek-related fields
+	cursorPos  int64
+	seekCalled bool
+
+	// read-related fields
+	reader     io.ReadCloser
+	readCalled bool
+
+	// write-related fields
+	tempFileWriter *os.File
+	gcsWriter      io.WriteCloser
+	cancelFunc     context.CancelFunc
+	writeCalled    bool
 }
 
-// Close cleans up underlying mechanisms for reading from and writing to the file. Closes and removes the
-// local temp file, and triggers a write to GCS of anything in the f.writeBuffer if it has been created.
+// Close commits any writes, either from the GCS writer stream or from a tempfile (in the case where Seek or Read are
+// called after Write).  It then cleans up any open resources and resets the file's state.
 func (f *File) Close() error {
-	if f.tempFile != nil {
-		defer func() { _ = f.tempFile.Close() }()
+	defer func() {
+		// reset state
+		f.reader = nil
+		f.cancelFunc = nil
+		f.gcsWriter = nil
+		f.cursorPos = 0
+		f.seekCalled = false
+		f.readCalled = false
+		f.writeCalled = false
+	}()
 
-		err := os.Remove(f.tempFile.Name())
-		if err != nil && !os.IsNotExist(err) {
-			return err
+	// cleanup reader (unless reader is also the writer tempfile)
+	if f.reader != nil && !f.writeCalled {
+		// close reader
+		if err := f.reader.Close(); err != nil {
+			return utils.WrapCloseError(err)
 		}
+	}
 
-		f.tempFile = nil
+	// finalize writer
+	if f.gcsWriter != nil {
+		// close gcsWriter
+		if err := f.gcsWriter.Close(); err != nil {
+			return utils.WrapCloseError(err)
+		}
+	} else if f.tempFileWriter != nil { // gcsWriter is nil but tempFileWriter is not nil (seek after write, write after seek)
+		// write tempFileWriter to gcs
+		if err := f.tempToGCS(); err != nil {
+			return utils.WrapCloseError(err)
+		}
 	}
 
-	if f.writeBuffer != nil {
+	// cleanup tempFileWriter
+	if f.tempFileWriter != nil {
+		if err := f.cleanupTempFile(); err != nil {
+			return utils.WrapCloseError(err)
+		}
+	}
 
-		handle, err := f.getObjectHandle()
+	// close reader
+	if f.reader != nil && !f.writeCalled {
+		err := f.reader.Close()
+		if err != nil {
+			return utils.WrapCloseError(err)
+		}
+	}
+
+	return nil
+}
+
+func (f *File) tempToGCS() error {
+
+	handle, err := f.getObjectHandle()
+	if err != nil {
+		return err
+	}
+
+	w := handle.NewWriter(f.fileSystem.ctx)
+	defer func() { _ = w.Close() }()
+
+	_, err = f.tempFileWriter.Seek(0, io.SeekStart)
+	if err != nil {
+		return err
+	}
+
+	buffer := make([]byte, utils.TouchCopyMinBufferSize)
+	if _, err := io.CopyBuffer(w, f.tempFileWriter, buffer); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (f *File) cleanupTempFile() error {
+	if f.tempFileWriter != nil {
+		err := f.tempFileWriter.Close()
 		if err != nil {
 			return err
 		}
 
-		ctx, cancel := context.WithCancel(f.fileSystem.ctx)
-		defer cancel()
-		w := handle.NewWriter(ctx)
-		defer func() { _ = w.Close() }()
-		buffer := make([]byte, utils.TouchCopyMinBufferSize)
-		if _, err := io.CopyBuffer(w, f.writeBuffer, buffer); err != nil {
-			// cancel context (replaces CloseWithError)
+		err = os.Remove(f.tempFileWriter.Name())
+		if err != nil {
 			return err
 		}
+
+		f.tempFileWriter = nil
 	}
 
-	f.writeBuffer = nil
 	return nil
 }
 
-// Read implements the standard for io.Reader. For this to work with an GCS file, a temporary local copy of
-// the file is created, and reads work on that. This file is closed and removed upon calling f.Close()
+// Read implements the standard for io.Reader.
 func (f *File) Read(p []byte) (n int, err error) {
-	if err := f.checkTempFile(); err != nil {
-		return 0, err
+	// check/initialize for reader
+	r, err := f.getReader()
+	if err != nil {
+		return 0, utils.WrapReadError(err)
+	}
+
+	read, err := r.Read(p)
+	if err != nil {
+		if errors.Is(err, io.EOF) {
+			return read, io.EOF
+		}
+		return read, utils.WrapReadError(err)
 	}
-	return f.tempFile.Read(p)
+
+	f.cursorPos += int64(read)
+	f.readCalled = true
+
+	return read, nil
 }
 
-// Seek implements the standard for io.Seeker. A temporary local copy of the GCS file is created (the same
-// one used for Reads) which Seek() acts on. This file is closed and removed upon calling f.Close()
+func (f *File) getReader() (io.ReadCloser, error) {
+	if f.reader == nil {
+		if f.writeCalled && f.tempFileWriter != nil {
+			// we've edited or truncated the file, so we need to read from the temp file which should already be at the
+			// current cursor position
+			f.reader = f.tempFileWriter
+		} else {
+			// get object handle
+			h, err := f.getObjectHandle()
+			if err != nil {
+				return nil, err
+			}
+
+			// get range reader (from current cursor position to end of file)
+			reader, err := h.NewRangeReader(f.fileSystem.ctx, f.cursorPos, -1)
+			if err != nil {
+				return nil, err
+			}
+
+			// Set the reader to the body of the object
+			f.reader = reader
+		}
+	}
+	return f.reader, nil
+}
+
+// Seek implements the standard for io.Seeker.
 func (f *File) Seek(offset int64, whence int) (int64, error) {
-	if err := f.checkTempFile(); err != nil {
-		return 0, err
+	// get length of file
+	var length uint64
+	if f.writeCalled {
+		// if write has been called, then the length is the cursorPos
+		length = uint64(f.cursorPos)
+	} else {
+		var err error
+		length, err = f.Size()
+		if err != nil {
+			return 0, utils.WrapSeekError(err)
+		}
+	}
+
+	// invalidate reader (if any)
+	if f.reader != nil {
+		err := f.reader.Close()
+		if err != nil {
+			return 0, utils.WrapSeekError(err)
+		}
+
+		f.reader = nil
+	}
+
+	// invalidate gcsWriter
+	if f.gcsWriter != nil {
+		// cancel gcsWriter
+		f.cancelFunc()
+		f.cancelFunc = nil
+
+		f.gcsWriter = nil
+	}
+
+	// update seek position for tempFileWriter writer (if any)
+	if f.tempFileWriter != nil {
+		// seek tempFileWriter
+		_, err := f.tempFileWriter.Seek(offset, whence)
+		if err != nil {
+			return 0, utils.WrapSeekError(err)
+		}
 	}
-	return f.tempFile.Seek(offset, whence)
+
+	// update cursorPos
+	pos, err := utils.SeekTo(int64(length), f.cursorPos, offset, whence)
+	if err != nil {
+		return 0, utils.WrapSeekError(err)
+	}
+	f.cursorPos = pos
+
+	f.seekCalled = true
+	return f.cursorPos, nil
 }
 
-// Write implements the standard for io.Writer. A buffer is added to with each subsequent
-// write. Calling Close() will write the contents back to GCS.
-func (f *File) Write(data []byte) (n int, err error) {
-	if f.writeBuffer == nil {
-		// note, initializing with 'data' and returning len(data), nil
-		// causes issues with some Write usages, notably csv.Writer
-		// so we simply initialize with no bytes and call the buffer Write after
-		//
-		// f.writeBuffer = bytes.NewBuffer(data)
-		// return len(data), nil
-		//
-		// so now we do:
+// Write implements the standard for io.Writer.  Note that writes are not committed to GCS until CLose() is called.
+func (f *File) Write(data []byte) (int, error) {
+	// Here, we initialize both a tempFileWriter and a gcsWriter if they haven't been initialized yet.
+	// Then write to both the local tempFileWriter and the gcsWriter stream.  We do this on the unlikely chance
+	// that the file is being written to is later Seek()'d to or Read() from before Close() is called.
+	// That would necessarily mean that the cursor for any later writes would change. Since we can't alter the current
+	// GCS stream, we cancel it and would need to write to the tempFileWriter only.  Any later Close() would then write
+	// the tempFileWriter to GCS.
+	// This is a rare case, but is meant to emulate the behavior of a standard POSIX file system.
+	// We might consider placing each write in a goroutine with a WaitGroup if this becomes a performance issue.
+
+	// check/initialize for writer
+	err := f.initWriters()
+	if err != nil {
+		return 0, utils.WrapWriteError(err)
+	}
+
+	// write to tempfile
+	written, err := f.tempFileWriter.Write(data)
+	if err != nil {
+		return 0, utils.WrapWriteError(err)
+	}
 
-		f.writeBuffer = bytes.NewBuffer([]byte{})
+	// write to gcs
+	if f.gcsWriter != nil {
+		// write to gcs
+		gcsWritten, err := f.gcsWriter.Write(data)
+		if err != nil {
+			return 0, utils.WrapWriteError(err)
+		}
 
+		// ensure both writes are the same
+		if written != gcsWritten {
+			return 0, utils.WrapWriteError(errors.New("writers wrote different amounts of data"))
+		}
 	}
-	return f.writeBuffer.Write(data)
+
+	// update cursorPos
+	f.cursorPos += int64(written)
+	f.writeCalled = true
+
+	return written, nil
+}
+
+func (f *File) initWriters() error {
+	if f.tempFileWriter == nil {
+		// Create temp file
+		tmpFile, err := os.CreateTemp("", fmt.Sprintf("vfs_gcs_%s.%d", f.Name(), time.Now().UnixNano()))
+		if err != nil {
+			return err
+		}
+		f.tempFileWriter = tmpFile
+		if f.cursorPos != 0 {
+			// if file exists(because cursor position is non-zero), we need to copy the existing gcsWriter file to temp
+			err := f.copyToLocalTempReader(tmpFile)
+			if err != nil {
+				return err
+			}
+
+			// seek to cursorPos
+			if _, err := f.tempFileWriter.Seek(f.cursorPos, 0); err != nil {
+				return err
+			}
+		}
+	}
+
+	// if we haven't seeked yet, we need to get the gcsWriter
+	if f.gcsWriter == nil {
+		if !f.seekCalled && !f.readCalled {
+			// setup cancelable context
+			ctx, cancel := context.WithCancel(f.fileSystem.ctx)
+			f.cancelFunc = cancel
+
+			// get object handle
+			handle, err := f.getObjectHandle()
+			if err != nil {
+				return err
+			}
+
+			// get gcsWriter
+			w := handle.NewWriter(ctx)
+			if err != nil {
+				return err
+			}
+
+			// set gcsWriter
+			f.gcsWriter = w
+		}
+	}
+
+	return nil
 }
 
 // String returns the file URI string.
@@ -227,7 +455,6 @@ func (f *File) MoveToFile(file vfs.File) error {
 // a DeleteObject call to GCS for the file. If DeleteAllVersions option is provided,
 // DeleteObject call is made to GCS for each version of the file. Returns any error returned by the API.
 func (f *File) Delete(opts ...options.DeleteOption) error {
-	f.writeBuffer = nil
 	if err := f.Close(); err != nil {
 		return err
 	}
@@ -428,52 +655,36 @@ func (f *File) URI() string {
 	return utils.GetFileURI(vfs.File(f))
 }
 
-func (f *File) checkTempFile() error {
-	if f.tempFile == nil {
-		localTempFile, err := f.copyToLocalTempReader()
-		if err != nil {
-			return err
-		}
-		f.tempFile = localTempFile
-	}
-	return nil
-}
-
-func (f *File) copyToLocalTempReader() (*os.File, error) {
-	tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s.%d", f.Name(), time.Now().UnixNano()))
-	if err != nil {
-		return nil, err
-	}
+func (f *File) copyToLocalTempReader(tmpFile *os.File) error {
 
 	handle, err := f.getObjectHandle()
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	outputReader, err := handle.NewReader(f.fileSystem.ctx)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	buffer := make([]byte, utils.TouchCopyMinBufferSize)
 	if _, err := io.CopyBuffer(tmpFile, outputReader, buffer); err != nil {
-		return nil, err
+		return err
 	}
 
 	if err := outputReader.Close(); err != nil {
 		if cerr := tmpFile.Close(); cerr != nil {
-			return nil, cerr
+			return cerr
 		}
-		return nil, err
+		return err
 	}
 
 	// Return cursor to the beginning of the new temp file
 	if _, err := tmpFile.Seek(0, 0); err != nil {
-		return nil, err
+		return err
 	}
 
-	// initialize temp ReadCloser
-	return tmpFile, nil
+	return nil
 }
 
 // getObjectHandle returns cached Object struct for file
diff --git a/backend/gs/fileSystem.go b/backend/gs/fileSystem.go
index de9075e..35b7805 100644
--- a/backend/gs/fileSystem.go
+++ b/backend/gs/fileSystem.go
@@ -6,6 +6,7 @@ import (
 
 	"cloud.google.com/go/storage"
 	"golang.org/x/net/context"
+	"google.golang.org/api/option"
 
 	"github.com/c2fo/vfs/v6"
 	"github.com/c2fo/vfs/v6/backend"
@@ -18,9 +19,10 @@ const name = "Google Cloud Storage"
 
 // FileSystem implements vfs.FileSystem for the GCS file system.
 type FileSystem struct {
-	client  *storage.Client
-	ctx     context.Context
-	options vfs.Options
+	client        *storage.Client
+	ctx           context.Context
+	options       vfs.Options
+	clientCreator ClientCreator
 }
 
 // Retry will return a retrier provided via options, or a no-op if none is provided.
@@ -83,7 +85,7 @@ func (fs *FileSystem) Scheme() string {
 func (fs *FileSystem) Client() (*storage.Client, error) {
 	if fs.client == nil {
 		gsClientOpts := parseClientOptions(fs.options)
-		client, err := storage.NewClient(fs.ctx, gsClientOpts...)
+		client, err := fs.clientCreator.NewClient(fs.ctx, gsClientOpts...)
 		if err != nil {
 			return nil, err
 		}
@@ -114,10 +116,26 @@ func (fs *FileSystem) WithClient(client *storage.Client) *FileSystem {
 	return fs
 }
 
+// ClientCreator defines an interface for creating a new Google Cloud Storage client.
+type ClientCreator interface {
+	NewClient(ctx context.Context, opts ...option.ClientOption) (*storage.Client, error)
+}
+
+// defaultClientCreator is the default implementation of ClientCreator.
+type defaultClientCreator struct{}
+
+// NewClient is a function that creates a new Google Cloud Storage client.
+func (d *defaultClientCreator) NewClient(ctx context.Context, opts ...option.ClientOption) (*storage.Client, error) {
+	return storage.NewClient(ctx, opts...)
+}
+
 // NewFileSystem initializer for FileSystem struct accepts google cloud storage client and returns Filesystem or error.
 func NewFileSystem() *FileSystem {
-	fs := &FileSystem{}
-	fs = fs.WithContext(context.Background())
+	fs := &FileSystem{
+		ctx:           context.Background(),
+		clientCreator: &defaultClientCreator{},
+	}
+
 	return fs
 }
 
diff --git a/backend/gs/objectHandleWrapper.go b/backend/gs/objectHandleWrapper.go
index 8bf8fb9..4dcccc3 100644
--- a/backend/gs/objectHandleWrapper.go
+++ b/backend/gs/objectHandleWrapper.go
@@ -2,6 +2,7 @@ package gs
 
 import (
 	"context"
+	"errors"
 
 	"cloud.google.com/go/storage"
 	"google.golang.org/api/iterator"
@@ -16,6 +17,7 @@ import (
 type ObjectHandleWrapper interface {
 	NewWriter(ctx context.Context) *storage.Writer
 	NewReader(ctx context.Context) (*storage.Reader, error)
+	NewRangeReader(ctx context.Context, offset, length int64) (*storage.Reader, error)
 	Attrs(ctx context.Context) (*storage.ObjectAttrs, error)
 	Delete(ctx context.Context) error
 	Update(ctx context.Context, attrs storage.ObjectAttrsToUpdate) (*storage.ObjectAttrs, error)
@@ -65,6 +67,23 @@ func (r *RetryObjectHandler) NewReader(ctx context.Context) (*storage.Reader, er
 	return reader, nil
 }
 
+// NewRangeReader creates a new Reader to read the contents of the object starting at an offset, wrapped in a retry.
+// ErrObjectNotExist will be returned if the object is not found.
+func (r *RetryObjectHandler) NewRangeReader(ctx context.Context, offset, length int64) (*storage.Reader, error) {
+	var reader *storage.Reader
+	if err := r.Retry(func() error {
+		var retryErr error
+		reader, retryErr = r.handler.NewRangeReader(ctx, offset, length)
+		if retryErr != nil {
+			return retryErr
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return reader, nil
+}
+
 // Attrs represents the metadata for a Google Cloud Storage (GCS) object, wrapped in a retry.
 func (r *RetryObjectHandler) Attrs(ctx context.Context) (*storage.ObjectAttrs, error) {
 	return objectAttributeRetry(r.Retry, func() (*storage.ObjectAttrs, error) {
@@ -126,7 +145,7 @@ func (c *Copier) Run(ctx context.Context) (*storage.ObjectAttrs, error) {
 func objectAttributeRetry(retry vfs.Retry, attrFunc func() (*storage.ObjectAttrs, error)) (*storage.ObjectAttrs, error) {
 	var attrs *storage.ObjectAttrs
 	attrs, err := attrFunc()
-	if err != nil && err != iterator.Done {
+	if err != nil && !errors.Is(err, iterator.Done) {
 		if err := retry(func() error {
 			var retryErr error
 			attrs, retryErr = attrFunc()
diff --git a/backend/s3/file.go b/backend/s3/file.go
index 7ff4d95..f29022e 100644
--- a/backend/s3/file.go
+++ b/backend/s3/file.go
@@ -450,7 +450,7 @@ func (f *File) Seek(offset int64, whence int) (int64, error) {
 	}
 
 	// update cursorPos
-	pos, err := seekTo(int64(length), f.cursorPos, offset, whence)
+	pos, err := utils.SeekTo(int64(length), f.cursorPos, offset, whence)
 	if err != nil {
 		return 0, err
 	}
@@ -460,27 +460,6 @@ func (f *File) Seek(offset int64, whence int) (int64, error) {
 	return f.cursorPos, nil
 }
 
-// seekTo is a helper function for Seek. It takes the current position, offset, whence, and length of the file
-// and returns the new position. It also checks for invalid offsets and returns an error if one is found.
-func seekTo(length, position, offset int64, whence int) (int64, error) {
-
-	switch whence {
-	default:
-		return 0, vfs.ErrSeekInvalidWhence
-	case io.SeekStart:
-		// this actually does nothing since the new position just becomes the offset but is here for completeness
-	case io.SeekCurrent:
-		offset += position
-	case io.SeekEnd:
-		offset += length
-	}
-	if offset < 0 {
-		return 0, vfs.ErrSeekInvalidOffset
-	}
-
-	return offset, nil
-}
-
 // Write implements the standard for io.Writer.  Note that writes are not committed to S3 until CLose() is called.
 func (f *File) Write(data []byte) (int, error) {
 	// check/initialize for writer
diff --git a/backend/sftp/options.go b/backend/sftp/options.go
index 7d43e31..20dc83d 100644
--- a/backend/sftp/options.go
+++ b/backend/sftp/options.go
@@ -1,6 +1,7 @@
 package sftp
 
 import (
+	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -214,7 +215,7 @@ func findHomeSystemKnownHosts(knownHostsFiles []string) ([]string, error) {
 
 	// check file existence first to prevent auto-vivification of file
 	found, err := foundFile(homeKnonwHostsPath)
-	if err != nil && err != os.ErrNotExist {
+	if err != nil && !errors.Is(err, os.ErrNotExist) {
 		return nil, err
 	}
 	if found {
@@ -226,7 +227,7 @@ func findHomeSystemKnownHosts(knownHostsFiles []string) ([]string, error) {
 	if runtime.GOOS != "windows" {
 		// check file existence first to prevent auto-vivification of file
 		found, err := foundFile(systemWideKnownHosts)
-		if err != nil && err != os.ErrNotExist {
+		if err != nil && !errors.Is(err, os.ErrNotExist) {
 			return nil, err
 		}
 		if found {
diff --git a/utils/errors.go b/utils/errors.go
new file mode 100644
index 0000000..094319c
--- /dev/null
+++ b/utils/errors.go
@@ -0,0 +1,23 @@
+package utils
+
+import "fmt"
+
+// WrapReadError returns a wrapped read error
+func WrapReadError(err error) error {
+	return fmt.Errorf("read error: %s", err.Error())
+}
+
+// WrapSeekError returns a wrapped seek error
+func WrapSeekError(err error) error {
+	return fmt.Errorf("seek error: %s", err.Error())
+}
+
+// WrapWriteError returns a wrapped write error
+func WrapWriteError(err error) error {
+	return fmt.Errorf("write error: %s", err.Error())
+}
+
+// WrapCloseError returns a wrapped close error
+func WrapCloseError(err error) error {
+	return fmt.Errorf("close error: %s", err.Error())
+}
diff --git a/utils/utils.go b/utils/utils.go
index b7e52c8..662d039 100644
--- a/utils/utils.go
+++ b/utils/utils.go
@@ -223,3 +223,24 @@ func UpdateLastModifiedByMoving(file vfs.File) error {
 	}
 	return nil
 }
+
+// SeekTo is a helper function for Seek. It takes the current position, offset, whence, and length of the file
+// and returns the new position. It also checks for invalid offsets and returns an error if one is found.
+func SeekTo(length, position, offset int64, whence int) (int64, error) {
+
+	switch whence {
+	default:
+		return 0, vfs.ErrSeekInvalidWhence
+	case io.SeekStart:
+		// this actually does nothing since the new position just becomes the offset but is here for completeness
+	case io.SeekCurrent:
+		offset += position
+	case io.SeekEnd:
+		offset += length
+	}
+	if offset < 0 {
+		return 0, vfs.ErrSeekInvalidOffset
+	}
+
+	return offset, nil
+}


Test patch:
diff --git a/backend/gs/filesystem_test.go b/backend/gs/filesystem_test.go
new file mode 100644
index 0000000..64a9fed
--- /dev/null
+++ b/backend/gs/filesystem_test.go
@@ -0,0 +1,229 @@
+package gs
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"cloud.google.com/go/storage"
+	"github.com/stretchr/testify/suite"
+	"google.golang.org/api/option"
+)
+
+type fileysystemSuite struct {
+	suite.Suite
+}
+
+func TestFilesystemSuite(t *testing.T) {
+	suite.Run(t, new(fileysystemSuite))
+}
+
+func (s *fileysystemSuite) TestNewFile() {
+	testCases := []struct {
+		description       string
+		volume            string
+		filename          string
+		expectedErrString string
+		nilFS             bool
+	}{
+		{
+			description:       "nil filesystem",
+			volume:            "bucket",
+			filename:          "/file.txt",
+			expectedErrString: "non-nil gs.FileSystem pointer is required",
+			nilFS:             true,
+		},
+		{
+			description:       "empty volume",
+			volume:            "",
+			filename:          "/file.txt",
+			expectedErrString: "non-empty strings for Bucket and Key are required",
+		},
+		{
+			description:       "empty filename",
+			volume:            "bucket",
+			filename:          "",
+			expectedErrString: "non-empty strings for Bucket and Key are required",
+		},
+		{
+			description:       "invalid filename",
+			volume:            "bucket",
+			filename:          "/file.txt/",
+			expectedErrString: "absolute file path is invalid - must include leading slash and may not include trailing slash",
+		},
+		{
+			description:       "valid filename",
+			volume:            "bucket",
+			filename:          "/file.txt",
+			expectedErrString: "",
+		},
+	}
+
+	for _, tc := range testCases {
+		s.Run(tc.description, func() {
+			fs := &FileSystem{}
+			if tc.nilFS {
+				fs = nil
+			}
+			_, err := fs.NewFile(tc.volume, tc.filename)
+			if tc.expectedErrString == "" {
+				s.NoError(err)
+				return
+			}
+			s.EqualError(err, tc.expectedErrString)
+		})
+	}
+}
+
+func (s *fileysystemSuite) TestNewLocation() {
+	testCases := []struct {
+		description       string
+		volume            string
+		name              string
+		expectedErrString string
+		nilFS             bool
+	}{
+		{
+			description:       "nil filesystem",
+			volume:            "bucket",
+			name:              "/",
+			expectedErrString: "non-nil gs.FileSystem pointer is required",
+			nilFS:             true,
+		},
+		{
+			description:       "empty volume",
+			volume:            "",
+			name:              "/",
+			expectedErrString: "non-empty strings for bucket and key are required",
+		},
+		{
+			description:       "empty name",
+			volume:            "bucket",
+			name:              "",
+			expectedErrString: "non-empty strings for bucket and key are required",
+		},
+		{
+			description:       "invalid name",
+			volume:            "bucket",
+			name:              "/path",
+			expectedErrString: "absolute location path is invalid - must include leading and trailing slashes",
+		},
+		{
+			description:       "valid name",
+			volume:            "bucket",
+			name:              "/path/",
+			expectedErrString: "",
+		},
+	}
+
+	for _, tc := range testCases {
+		s.Run(tc.description, func() {
+			fs := &FileSystem{}
+			if tc.nilFS {
+				fs = nil
+			}
+			_, err := fs.NewLocation(tc.volume, tc.name)
+			if tc.expectedErrString == "" {
+				s.NoError(err)
+				return
+			}
+			s.EqualError(err, tc.expectedErrString)
+		})
+	}
+}
+
+func (s *fileysystemSuite) TestName() {
+	fs := &FileSystem{}
+	s.Equal(name, fs.Name())
+}
+
+func (s *fileysystemSuite) TestRetry() {
+	sentinel := errors.New("sentinel")
+	fs := &FileSystem{
+		options: Options{
+			Retry: func(wrapped func() error) error {
+				return sentinel
+			},
+		},
+	}
+	s.Equal(sentinel, fs.Retry()(nil))
+}
+
+type mockClientCreatorWithError struct{}
+
+func (c *mockClientCreatorWithError) NewClient(ctx context.Context, opts ...option.ClientOption) (*storage.Client, error) {
+	return nil, errors.New("mock error")
+}
+
+type mockClientCreator struct{}
+
+func (c *mockClientCreator) NewClient(ctx context.Context, opts ...option.ClientOption) (*storage.Client, error) {
+	return &storage.Client{}, nil
+}
+
+func (s *fileysystemSuite) TestClient() {
+	testCases := []struct {
+		name         string
+		setup        func() *FileSystem
+		expectError  bool
+		expectNotNil bool
+	}{
+		{
+			name: "With predefined client",
+			setup: func() *FileSystem {
+				return &FileSystem{
+					client: &storage.Client{},
+				}
+			},
+			expectError:  false,
+			expectNotNil: true,
+		},
+		{
+			name: "New FileSystem without predefined client",
+			setup: func() *FileSystem {
+				return &FileSystem{
+					clientCreator: &mockClientCreator{},
+				}
+			},
+			expectError:  false,
+			expectNotNil: true,
+		},
+		{
+			name: "New FileSystem with error",
+			setup: func() *FileSystem {
+				return &FileSystem{
+					clientCreator: &mockClientCreatorWithError{},
+				}
+			},
+			expectError:  true,
+			expectNotNil: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		s.Run(tc.name, func() {
+			fs := tc.setup()
+
+			client, err := fs.Client()
+
+			if tc.expectError {
+				s.Error(err)
+			} else {
+				s.NoError(err)
+			}
+
+			if tc.expectNotNil {
+				s.NotNil(client)
+			} else {
+				s.Nil(client)
+			}
+		})
+	}
+}
+
+func (s *fileysystemSuite) TestWithContext() {
+	fs := &FileSystem{}
+	ctx := context.Background()
+	fs = fs.WithContext(ctx)
+	s.Equal(ctx, fs.ctx)
+}
diff --git a/backend/gs/options_test.go b/backend/gs/options_test.go
new file mode 100644
index 0000000..e051117
--- /dev/null
+++ b/backend/gs/options_test.go
@@ -0,0 +1,74 @@
+package gs
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+)
+
+type optionsSuite struct {
+	suite.Suite
+}
+
+func TestOptionsSuite(t *testing.T) {
+	suite.Run(t, new(optionsSuite))
+}
+
+func (s *optionsSuite) TestParseClientOptions() {
+	testCases := []struct {
+		name            string
+		input           Options
+		expectedOptions int // Count of expectedErrString Google client options
+	}{
+		{
+			name: "API Key only",
+			input: Options{
+				APIKey: "test-api-key",
+			},
+			expectedOptions: 1,
+		},
+		{
+			name: "Credential File only",
+			input: Options{
+				CredentialFile: "path/to/credential/file.json",
+			},
+			expectedOptions: 1,
+		},
+		{
+			name: "Endpoint only",
+			input: Options{
+				Endpoint: "custom-endpoint",
+			},
+			expectedOptions: 1,
+		},
+		{
+			name: "Scopes only",
+			input: Options{
+				Scopes: []string{"scope1", "scope2"},
+			},
+			expectedOptions: 1,
+		},
+		{
+			name: "Multiple options",
+			input: Options{
+				APIKey:         "test-api-key",
+				CredentialFile: "path/to/credential/file.json",
+				Endpoint:       "custom-endpoint",
+				Scopes:         []string{"scope1", "scope2"},
+			},
+			expectedOptions: 1, // The function currently selects the first matching case
+		},
+		{
+			name:            "No options",
+			input:           Options{},
+			expectedOptions: 0,
+		},
+	}
+
+	for _, tc := range testCases {
+		s.Run(tc.name, func() {
+			result := parseClientOptions(tc.input)
+			s.Len(result, tc.expectedOptions, "unexpected number of client options returned")
+		})
+	}
+}
diff --git a/backend/s3/file_test.go b/backend/s3/file_test.go
index 6eaa48f..50db287 100644
--- a/backend/s3/file_test.go
+++ b/backend/s3/file_test.go
@@ -766,53 +766,6 @@ func (ts *fileTestSuite) TestCloseWithWrite() {
 
 }
 
-// TestSeekTo tests the seekTo function with various cases
-func (ts *fileTestSuite) TestSeekTo() {
-	testCases := []struct {
-		position         int64
-		offset           int64
-		whence           int
-		length           int64
-		expectedPosition int64
-		expectError      error
-	}{
-		// Test seeking from start
-		{0, 10, io.SeekStart, 100, 10, nil},
-		{0, -10, io.SeekStart, 100, 0, vfs.ErrSeekInvalidOffset}, // Negative offset from start
-		{0, 110, io.SeekStart, 100, 110, nil},                    // Offset beyond length
-
-		// Test seeking from current position
-		{50, 10, io.SeekCurrent, 100, 60, nil},
-		{50, -60, io.SeekCurrent, 100, 0, vfs.ErrSeekInvalidOffset}, // Moving before start
-		{50, 60, io.SeekCurrent, 100, 110, nil},                     // Moving beyond length
-
-		// Test seeking from end
-		{0, -10, io.SeekEnd, 100, 90, nil},
-		{0, -110, io.SeekEnd, 100, 0, vfs.ErrSeekInvalidOffset}, // Moving before start
-		{0, 10, io.SeekEnd, 100, 110, nil},                      // Moving beyond length
-
-		// Additional edge cases
-		{0, 0, io.SeekStart, 100, 0, nil},       // No movement from start
-		{100, 0, io.SeekCurrent, 100, 100, nil}, // No movement from current
-		{0, 0, io.SeekEnd, 100, 100, nil},       // No movement from end
-
-		// invalid whence case
-		{0, 0, 3, 100, 0, vfs.ErrSeekInvalidWhence},
-	}
-
-	for _, tc := range testCases {
-		result, err := seekTo(tc.length, tc.position, tc.offset, tc.whence)
-
-		if tc.expectError != nil {
-			ts.Error(err, "error expected")
-			ts.ErrorIs(err, tc.expectError)
-		} else {
-			ts.NoError(err, "no error expected")
-			ts.Equal(tc.expectedPosition, result)
-		}
-	}
-}
-
 type fileTestCase struct {
 	name             string
 	setup            func(*mocks.S3API) *File // Function to set up each test case
diff --git a/utils/utils_test.go b/utils/utils_test.go
index 8eb0f7e..9f2be70 100644
--- a/utils/utils_test.go
+++ b/utils/utils_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/suite"
 
+	"github.com/c2fo/vfs/v6"
 	_os "github.com/c2fo/vfs/v6/backend/os"
 	"github.com/c2fo/vfs/v6/mocks"
 	"github.com/c2fo/vfs/v6/utils"
@@ -892,6 +893,53 @@ func (s *utilsSuite) TestTouchCopyBufferedNonDefaultBufferSize() {
 
 }
 
+// TestSeekTo tests the seekTo function with various cases
+func (s *utilsSuite) TestSeekTo() {
+	testCases := []struct {
+		position         int64
+		offset           int64
+		whence           int
+		length           int64
+		expectedPosition int64
+		expectError      error
+	}{
+		// Test seeking from start
+		{0, 10, io.SeekStart, 100, 10, nil},
+		{0, -10, io.SeekStart, 100, 0, vfs.ErrSeekInvalidOffset}, // Negative offset from start
+		{0, 110, io.SeekStart, 100, 110, nil},                    // Offset beyond length
+
+		// Test seeking from current position
+		{50, 10, io.SeekCurrent, 100, 60, nil},
+		{50, -60, io.SeekCurrent, 100, 0, vfs.ErrSeekInvalidOffset}, // Moving before start
+		{50, 60, io.SeekCurrent, 100, 110, nil},                     // Moving beyond length
+
+		// Test seeking from end
+		{0, -10, io.SeekEnd, 100, 90, nil},
+		{0, -110, io.SeekEnd, 100, 0, vfs.ErrSeekInvalidOffset}, // Moving before start
+		{0, 10, io.SeekEnd, 100, 110, nil},                      // Moving beyond length
+
+		// Additional edge cases
+		{0, 0, io.SeekStart, 100, 0, nil},       // No movement from start
+		{100, 0, io.SeekCurrent, 100, 100, nil}, // No movement from current
+		{0, 0, io.SeekEnd, 100, 100, nil},       // No movement from end
+
+		// invalid whence case
+		{0, 0, 3, 100, 0, vfs.ErrSeekInvalidWhence},
+	}
+
+	for _, tc := range testCases {
+		result, err := utils.SeekTo(tc.length, tc.position, tc.offset, tc.whence)
+
+		if tc.expectError != nil {
+			s.Error(err, "error expected")
+			s.ErrorIs(err, tc.expectError)
+		} else {
+			s.NoError(err, "no error expected")
+			s.Equal(tc.expectedPosition, result)
+		}
+	}
+}
+
 func TestUtils(t *testing.T) {
 	suite.Run(t, new(utilsSuite))
 }