Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
878f5ca
Add Stat and Open interfaces
ThisaraWeerakoon May 11, 2025
ce6bcec
Add error wrapper for stat method
ThisaraWeerakoon May 11, 2025
da48767
Add implementations of Stat and Open into mem backend
ThisaraWeerakoon May 11, 2025
c594061
Add mock implementation of Stat method
ThisaraWeerakoon May 11, 2025
c98a85e
Add mock implementation for Open method at Location
ThisaraWeerakoon May 11, 2025
957cc9c
Add Stat and Open implementations for os backend
ThisaraWeerakoon May 11, 2025
f54b930
Add implementations for azure backend
ThisaraWeerakoon May 11, 2025
9323f47
Add implementation for ftp
ThisaraWeerakoon May 11, 2025
b364739
Add implementation for google storage
ThisaraWeerakoon May 11, 2025
15d9a12
Add implementation for s3
ThisaraWeerakoon May 11, 2025
7cf3553
Add implementation for sftp
ThisaraWeerakoon May 11, 2025
bdfd860
Update changelog Fixes #163
ThisaraWeerakoon May 11, 2025
c28e096
Add tests for gs file
ThisaraWeerakoon May 11, 2025
2fc5a9b
Add tests for Stat and Open in mem backend
ThisaraWeerakoon May 11, 2025
83f68de
Add Open test for gs backend
ThisaraWeerakoon May 11, 2025
d360f20
Add Stat() test for os backend
ThisaraWeerakoon May 11, 2025
ccde0cd
Open test for os backend
ThisaraWeerakoon May 11, 2025
dcdae5c
Add Stat() test for s3 backend
ThisaraWeerakoon May 11, 2025
42faa1d
Add Open() test for s3 backend
ThisaraWeerakoon May 11, 2025
31693b7
Add new test to azure file test
ThisaraWeerakoon May 11, 2025
10e3080
Fix windows os backend test issue
ThisaraWeerakoon May 11, 2025
8349113
Fix lint issues
ThisaraWeerakoon May 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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]
- Update vfs to implement io/fs's fs.FS interface fixes #163

## [v7.4.1] - 2025-05-05
### Security
Expand Down
60 changes: 60 additions & 0 deletions backend/azure/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"strings"
Expand Down Expand Up @@ -66,6 +67,65 @@ func (f *File) Close() error {
return nil
}

// Stat returns a fs.FileInfo describing the file.
// This implements the fs.File interface from io/fs.
func (f *File) Stat() (fs.FileInfo, error) {
exists, err := f.Exists()
if err != nil {
return nil, utils.WrapStatError(err)
}
if !exists {
return nil, fs.ErrNotExist
}

size, err := f.Size()
if err != nil {
return nil, utils.WrapStatError(err)
}

lastMod, err := f.LastModified()
if err != nil {
return nil, utils.WrapStatError(err)
}

return &azureFileInfo{
name: f.Name(),
size: int64(size),
modTime: *lastMod,
}, nil
}

// azureFileInfo implements fs.FileInfo for Azure blobs
type azureFileInfo struct {
name string
size int64
modTime time.Time
}

func (fi *azureFileInfo) Name() string {
return fi.name
}

func (fi *azureFileInfo) Size() int64 {
return fi.size
}

func (fi *azureFileInfo) Mode() fs.FileMode {
return 0644 // Default permission for files
}

func (fi *azureFileInfo) ModTime() time.Time {
return fi.modTime
}

func (fi *azureFileInfo) IsDir() bool {
return false // Azure blobs are always files, not directories
}

func (fi *azureFileInfo) Sys() interface{} {
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.
Expand Down
37 changes: 37 additions & 0 deletions backend/azure/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,43 @@ func (s *FileTestSuite) TestIsSameAuth_DifferentAcctKey() {
s.False(sourceFile.isSameAuth(targetFile), "Files were created with different account keys so same auth should be false")
}

func (s *FileTestSuite) TestStat() {
// Setup test values
fileContent := "Hello, this is a test file content."
modTime := time.Now().UTC()

// Mock properties response
client := MockAzureClient{
PropertiesResult: &BlobProperties{
Size: to.Ptr(int64(len(fileContent))),
LastModified: to.Ptr(modTime),
},
}
fs := NewFileSystem(WithClient(&client))

// Create a test file
f, err := fs.NewFile("test-container", "/foo/bar.txt")
s.NoError(err, "Creating file shouldn't return an error")

// Test successful stat
fileInfo, err := f.Stat()
s.NoError(err, "Stat() should not return an error for existing file")
s.NotNil(fileInfo, "FileInfo should not be nil")
s.Equal("bar.txt", fileInfo.Name(), "FileInfo name should match file name")
s.Equal(int64(len(fileContent)), fileInfo.Size(), "FileInfo size should match content length")
s.False(fileInfo.IsDir(), "FileInfo should indicate file is not a directory")
s.Equal(modTime, fileInfo.ModTime(), "ModTime should match")

// Test error case
client = MockAzureClient{PropertiesError: errors.New("blob not found")}
fs = NewFileSystem(WithClient(&client))
f, _ = fs.NewFile("test-container", "/foo/non-existent.txt")

_, err = f.Stat()
s.Error(err, "Stat() should return an error for non-existent file")
s.Contains(err.Error(), "blob not found", "Error should indicate file does not exist")
}

func TestAzureFile(t *testing.T) {
suite.Run(t, new(FileTestSuite))
}
30 changes: 30 additions & 0 deletions backend/azure/location.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package azure

import (
"errors"
"io/fs"
"path"
"regexp"
"strings"
Expand Down Expand Up @@ -205,6 +206,35 @@ func (l *Location) NewFile(relFilePath string, opts ...options.NewFileOption) (v
}, nil
}

// Open opens the named file at this location.
// This implements the fs.FS interface from io/fs.
func (l *Location) Open(name string) (fs.File, error) {
// fs.FS expects paths with no leading slash
name = strings.TrimPrefix(name, "/")

// For io/fs compliance, we need to validate that it doesn't contain "." or ".." elements
if name == "." || name == ".." || strings.Contains(name, "/.") || strings.Contains(name, "./") {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}

// Create a standard vfs file using NewFile
vfsFile, err := l.NewFile(name)
if err != nil {
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}

// Check if the file exists, as fs.FS.Open requires the file to exist
exists, err := vfsFile.Exists()
if err != nil {
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}
if !exists {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}

return vfsFile, nil
}

// DeleteFile deletes the file at the given path, relative to the current location.
func (l *Location) DeleteFile(relFilePath string, opts ...options.DeleteOption) error {
file, err := l.NewFile(utils.RemoveLeadingSlash(relFilePath))
Expand Down
47 changes: 47 additions & 0 deletions backend/ftp/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
stdfs "io/fs"
"os"
"path"
"strconv"
Expand Down Expand Up @@ -79,6 +80,52 @@ func (f *File) stat(ctx context.Context) (*_ftp.Entry, error) {
}
}

// Stat returns a fs.FileInfo describing the file.
// This implements the fs.File interface from io/fs.
func (f *File) Stat() (stdfs.FileInfo, error) {
entry, err := f.stat(context.TODO())
if err != nil {
return nil, utils.WrapStatError(err)
}

return &ftpFileInfo{
name: f.Name(),
size: int64(entry.Size),
modTime: entry.Time,
}, nil
}

// ftpFileInfo implements fs.FileInfo for FTP files
type ftpFileInfo struct {
name string
size int64
modTime time.Time
}

func (fi *ftpFileInfo) Name() string {
return fi.name
}

func (fi *ftpFileInfo) Size() int64 {
return fi.size
}

func (fi *ftpFileInfo) Mode() stdfs.FileMode {
return 0644 // Default permission for files
}

func (fi *ftpFileInfo) ModTime() time.Time {
return fi.modTime
}

func (fi *ftpFileInfo) IsDir() bool {
return false // FTP files represented by File struct are always files, not directories
}

func (fi *ftpFileInfo) Sys() interface{} {
return nil
}

// Name returns the path portion of the file's path property. IE: "file.txt" of "ftp://someuser@host.com/some/path/to/file.txt
func (f *File) Name() string {
return path.Base(f.path)
Expand Down
30 changes: 30 additions & 0 deletions backend/ftp/location.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
stdfs "io/fs"
"path"
"regexp"
"strings"
Expand Down Expand Up @@ -265,3 +266,32 @@ func (l *Location) URI() string {
func (l *Location) String() string {
return l.URI()
}

// Open opens the named file at this location.
// This implements the fs.FS interface from io/fs.
func (l *Location) Open(name string) (stdfs.File, error) {
// fs.FS expects paths with no leading slash
name = strings.TrimPrefix(name, "/")

// For io/fs compliance, we need to validate that it doesn't contain "." or ".." elements
if name == "." || name == ".." || strings.Contains(name, "/.") || strings.Contains(name, "./") {
return nil, &stdfs.PathError{Op: "open", Path: name, Err: stdfs.ErrInvalid}
}

// Create a standard vfs file using NewFile
vfsFile, err := l.NewFile(name)
if err != nil {
return nil, &stdfs.PathError{Op: "open", Path: name, Err: err}
}

// Check if the file exists, as fs.FS.Open requires the file to exist
exists, err := vfsFile.Exists()
if err != nil {
return nil, &stdfs.PathError{Op: "open", Path: name, Err: err}
}
if !exists {
return nil, &stdfs.PathError{Op: "open", Path: name, Err: stdfs.ErrNotExist}
}

return vfsFile, nil
}
49 changes: 49 additions & 0 deletions backend/gs/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"cloud.google.com/go/storage"
"google.golang.org/api/iterator"

"io/fs"

"github.com/c2fo/vfs/v7"
"github.com/c2fo/vfs/v7/backend"
"github.com/c2fo/vfs/v7/options"
Expand Down Expand Up @@ -663,6 +665,53 @@ func (f *File) Size() (uint64, error) {
return uint64(attr.Size), nil
}

// Stat returns a fs.FileInfo describing the file.
// This implements the fs.File interface from io/fs.
func (f *File) Stat() (fs.FileInfo, error) {
// Get file attributes from Google Cloud Storage
attrs, err := f.getObjectAttrs()
if err != nil {
return nil, utils.WrapStatError(err)
}

return &gsFileInfo{
name: f.Name(),
size: attrs.Size,
modTime: attrs.Updated,
}, nil
}

// gsFileInfo implements fs.FileInfo for Google Cloud Storage files
type gsFileInfo struct {
name string
size int64
modTime time.Time
}

func (fi *gsFileInfo) Name() string {
return fi.name
}

func (fi *gsFileInfo) Size() int64 {
return fi.size
}

func (fi *gsFileInfo) Mode() fs.FileMode {
return 0644 // Default permission for files
}

func (fi *gsFileInfo) ModTime() time.Time {
return fi.modTime
}

func (fi *gsFileInfo) IsDir() bool {
return false // GS files represented by File struct are always files, not directories
}

func (fi *gsFileInfo) Sys() interface{} {
return nil
}

// Path returns full path with leading slash of the GCS file key.
func (f *File) Path() string {
return f.key
Expand Down
Loading
Loading