From 0619e47bc33beb46dd1238eeae39be32fa97afcb Mon Sep 17 00:00:00 2001 From: spf13 Date: Thu, 12 Mar 2026 16:38:25 -0400 Subject: [PATCH 1/2] feat: add BridgeIOFS adapter and update README - Add NewBridgeIOFS to import any io/fs.FS as a read-only afero.Fs - Add iofsbridge_test.go with full test coverage - Update README: bidirectional io/fs bridge section, BridgeIOFS in backend reference table, new embedded assets + CopyOnWrite use case, fix incorrect afero.FromIOFS reference to afero.NewBridgeIOFS --- README.md | 109 ++++++++++-- iofsbridge.go | 171 ++++++++++++++++++ iofsbridge_test.go | 432 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 693 insertions(+), 19 deletions(-) create mode 100644 iofsbridge.go create mode 100644 iofsbridge_test.go diff --git a/README.md b/README.md index 05f7d2c7..0d5cc278 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Afero elevates filesystem interaction beyond simple file reading and writing, of * **Caching:** Use `CacheOnReadFs` to automatically layer a fast cache (like memory) over a slow backend (like a network drive). * **Security Jails:** Use `BasePathFs` to restrict application access to a specific subdirectory (chroot). * **`os` Package Compatibility:** Afero mirrors the functions in the standard `os` package, making adoption and refactoring seamless. -* **`io/fs` Compatibility:** Fully compatible with the Go standard library's `io/fs` interfaces. +* **Bidirectional `io/fs` Bridge:** Fully compatible with the Go standard library's `io/fs` interfaces—in both directions. Export any Afero filesystem as an `fs.FS` with `NewIOFS`, or import any `fs.FS` (including `embed.FS`) into Afero with `NewBridgeIOFS`. ## Installation @@ -280,6 +280,40 @@ func main() { } ``` +### Layering Embedded Assets with Copy-on-Write + +One of the most powerful patterns unlocked by `NewBridgeIOFS`: use Go's native `//go:embed` assets as the read-only base of a `CopyOnWriteFs`. Your application gets the embedded defaults at startup, while any runtime writes are safely isolated in memory. + +```go +import ( + "embed" + "github.com/spf13/afero" +) + +//go:embed configs/* +var embeddedConfigs embed.FS + +func NewAppFs() afero.Fs { + // Import the read-only embedded assets into Afero. + base := afero.NewBridgeIOFS(embeddedConfigs) + + // Layer a writable in-memory filesystem on top. + // Reads fall through to the embedded assets; writes stay in memory. + overlay := afero.NewMemMapFs() + return afero.NewCopyOnWriteFs(base, overlay) +} + +func main() { + fs := NewAppFs() + + // Reads the embedded default — no disk access. + cfg, _ := afero.ReadFile(fs, "configs/default.yaml") + + // Writes go to the in-memory overlay; embedded assets are untouched. + afero.WriteFile(fs, "configs/default.yaml", []byte("override: true"), 0644) +} +``` + ### Testing Made Simple One of Afero's greatest strengths is making filesystem-dependent code easily testable: @@ -320,28 +354,65 @@ func TestSaveUserData(t *testing.T) { - 🔒 **Safe** - Can't accidentally modify real files - 🏃 **Parallel** - Tests can run concurrently without conflicts -## Afero vs. `io/fs` (Go 1.16+) +## Afero & the Go Standard Library (`io/fs`) + +Go 1.16 introduced `io/fs`, a standard abstraction for **read-only** filesystems. Afero bridges this gap in **both directions**, so you can freely move between the Afero and standard library worlds. -Go 1.16 introduced the `io/fs` package, which provides a standard abstraction for **read-only** filesystems. +### When to use which -Afero complements `io/fs` by focusing on different needs: +| Situation | Recommendation | +| :--- | :--- | +| You only need to **read** files and want strict stdlib compatibility | Use `io/fs` directly | +| You need to **create, write, modify, or delete** files | Use Afero | +| You want to test complex read/write interactions | Use Afero (`MemMapFs`) | +| You need Copy-on-Write, Caching, or other composition | Use Afero | +| You have an `io/fs.FS` source and want Afero's full API | Use `NewBridgeIOFS` | -* **Use `io/fs` when:** You only need to read files and want to conform strictly to the standard library interfaces. -* **Use Afero when:** - * Your application needs to **create, write, modify, or delete** files. - * You need to test complex read/write interactions (e.g., renaming, concurrent writes). - * You need advanced compositional features (Copy-on-Write, Caching, etc.). +### Exporting Afero → `io/fs.FS` with `NewIOFS` -Afero is fully compatible with `io/fs`. You can wrap any Afero filesystem to satisfy the `fs.FS` interface using `afero.NewIOFS`: +Wrap any Afero filesystem to satisfy the `fs.FS` interface. This lets you pass a writable Afero filesystem to any standard library function that accepts `fs.FS`. ```go -import "io/fs" +import ( + "io/fs" + "github.com/spf13/afero" +) -// Create an Afero filesystem (writable) -var myAferoFs afero.Fs = afero.NewMemMapFs() +// A writable Afero filesystem +myAferoFs := afero.NewMemMapFs() +afero.WriteFile(myAferoFs, "hello.txt", []byte("hello"), 0644) -// Convert it to a standard library fs.FS (read-only view) +// Export as a read-only standard library fs.FS var myIoFs fs.FS = afero.NewIOFS(myAferoFs) + +// Now compatible with fs.WalkDir, http.FS, template.ParseFS, etc. +fs.WalkDir(myIoFs, ".", func(path string, d fs.DirEntry, err error) error { + // ... + return nil +}) +``` + +### Importing `io/fs.FS` → Afero with `NewBridgeIOFS` + +Bring any `io/fs.FS` source—such as Go's built-in `embed.FS`—into Afero as a read-only `Fs`. This makes it composable with all of Afero's layer and filter types. + +```go +import ( + "embed" + "github.com/spf13/afero" +) + +//go:embed assets/* +var embeddedAssets embed.FS + +// Import the embedded assets into Afero (read-only). +// Write operations return os.ErrPermission. +embeddedFs := afero.NewBridgeIOFS(embeddedAssets) + +// Now use it anywhere an afero.Fs is expected—including composition. +// For example, layer a writable overlay on top for testing: +overlay := afero.NewMemMapFs() +testFs := afero.NewCopyOnWriteFs(embeddedFs, overlay) ``` ## Third-Party Backends & Ecosystem @@ -433,7 +504,7 @@ Mount any Afero filesystem as a Windows drive letter. Brilliant demonstration of ### Modern Asset Embedding (Go 1.16+) -Instead of third-party tools, use Go's native `//go:embed` with Afero: +Instead of third-party tools, use Go's native `//go:embed` with Afero via `NewBridgeIOFS`: ```go import ( @@ -445,10 +516,10 @@ import ( var assetsFS embed.FS func main() { - // Convert embedded files to Afero filesystem - fs := afero.FromIOFS(assetsFS) - - // Use like any other Afero filesystem + // Import the embedded assets into Afero as a read-only filesystem. + fs := afero.NewBridgeIOFS(assetsFS) + + // Use the full Afero API against the embedded files. content, _ := afero.ReadFile(fs, "assets/config.json") } ``` diff --git a/iofsbridge.go b/iofsbridge.go new file mode 100644 index 00000000..97cadcf4 --- /dev/null +++ b/iofsbridge.go @@ -0,0 +1,171 @@ +package afero + +import ( + "fmt" + "io" + "io/fs" + "os" + "time" +) + +// errReadOnlyBridge is returned when a write operation is attempted on the read-only FS. +// It wraps os.ErrPermission. +var errReadOnlyBridge = fmt.Errorf("BridgeIOFS is read-only: %w", os.ErrPermission) + +var ErrNoSeek = fmt.Errorf("underlying fs.File does not support Seek: %w", os.ErrInvalid) +var ErrNoReadAt = fmt.Errorf("underlying fs.File does not support ReadAt: %w", os.ErrInvalid) +var ErrNotDir = fmt.Errorf("not a directory: %w", os.ErrInvalid) +var ErrNoLock = fmt.Errorf("underlying fs.File does not support Lock/Unlock: %w", os.ErrInvalid) + +// BridgeIOFS is a bridge that adapts an io/fs.FS to an afero.Fs. +// Since io/fs.FS is defined as read-only, this implementation is also read-only. +type BridgeIOFS struct { + backend fs.FS +} + +// NewBridgeIOFS creates a new read-only afero.Fs from an io/fs.FS. +func NewBridgeIOFS(backend fs.FS) Fs { + return &BridgeIOFS{backend: backend} +} + +// Name returns the name of the filesystem, including the underlying type for debugging. +func (f *BridgeIOFS) Name() string { + return fmt.Sprintf("BridgeIOFS(%T)", f.backend) +} + +// Stat returns the FileInfo structure describing the file. +func (f *BridgeIOFS) Stat(name string) (fs.FileInfo, error) { + // fs.Stat efficiently handles checking if the backend implements fs.StatFS. + return fs.Stat(f.backend, name) +} + +// Open opens the named file for reading. +func (f *BridgeIOFS) Open(name string) (File, error) { + file, err := f.backend.Open(name) + if err != nil { + return nil, err + } + // Wrap the fs.File in our adapter. Afero requires the File object to have a Name() method. + return &bridgeFile{file: file, name: name}, nil +} + +// OpenFile opens the named file. It enforces read-only access. +func (f *BridgeIOFS) OpenFile(name string, flag int, perm fs.FileMode) (File, error) { + // Check flags to ensure no write modes are requested. O_RDONLY is 0. + if flag&(os.O_WRONLY|os.O_RDWR|os.O_APPEND|os.O_CREATE|os.O_TRUNC) != 0 { + return nil, errReadOnlyBridge + } + return f.Open(name) +} + +// --- Write operations (return read-only error) --- + +func (f *BridgeIOFS) Create(name string) (File, error) { return nil, errReadOnlyBridge } +func (f *BridgeIOFS) Mkdir(name string, perm fs.FileMode) error { return errReadOnlyBridge } +func (f *BridgeIOFS) MkdirAll(path string, perm fs.FileMode) error { return errReadOnlyBridge } +func (f *BridgeIOFS) Remove(name string) error { return errReadOnlyBridge } +func (f *BridgeIOFS) RemoveAll(path string) error { return errReadOnlyBridge } +func (f *BridgeIOFS) Rename(oldname, newname string) error { return errReadOnlyBridge } +func (f *BridgeIOFS) Chmod(name string, mode fs.FileMode) error { return errReadOnlyBridge } +func (f *BridgeIOFS) Chtimes(name string, atime time.Time, mtime time.Time) error { + return errReadOnlyBridge +} +func (f *BridgeIOFS) Chown(name string, uid, gid int) error { return errReadOnlyBridge } + +// === File Implementation === + +// bridgeFile wraps fs.File to implement afero.File (read-only). +type bridgeFile struct { + file fs.File + name string // Store the name used to open the file. +} + +func (f *bridgeFile) Close() error { return f.file.Close() } +func (f *bridgeFile) Stat() (fs.FileInfo, error) { return f.file.Stat() } +func (f *bridgeFile) Name() string { return f.name } +func (f *bridgeFile) Read(p []byte) (int, error) { return f.file.Read(p) } + +// ReadAt attempts to use io.ReaderAt if the underlying fs.File supports it. +func (f *bridgeFile) ReadAt(p []byte, off int64) (n int, err error) { + if r, ok := f.file.(io.ReaderAt); ok { + return r.ReadAt(p, off) + } + // io/fs.File does not guarantee ReaderAt support. Use Afero standard error wrapped in PathError. + return 0, &os.PathError{Op: "readat", Path: f.name, Err: ErrNoReadAt} +} + +// Seek attempts to use io.Seeker if the underlying fs.File supports it. +func (f *bridgeFile) Seek(offset int64, whence int) (int64, error) { + if s, ok := f.file.(io.Seeker); ok { + return s.Seek(offset, whence) + } + // io/fs.File does not guarantee Seeker support. Use Afero standard error wrapped in PathError. + return 0, &os.PathError{Op: "seek", Path: f.name, Err: ErrNoSeek} +} + +// Readdir reads the contents of the directory. +func (f *bridgeFile) Readdir(count int) ([]fs.FileInfo, error) { + // According to io/fs.File spec, if the opened file is a directory, it should implement fs.ReadDirFile. + d, ok := f.file.(fs.ReadDirFile) + if !ok { + // If it doesn't implement the interface, treat it as not a directory. + return nil, &os.PathError{Op: "readdir", Path: f.name, Err: ErrNotDir} + } + + // ReadDir returns []fs.DirEntry + entries, err := d.ReadDir(count) + if err != nil { + return nil, err + } + + // Convert []fs.DirEntry to []fs.FileInfo (required by Afero) + infos := make([]fs.FileInfo, 0, len(entries)) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + // If we cannot get FileInfo from DirEntry (e.g., broken symlink). + return nil, fmt.Errorf("error getting FileInfo for %s: %w", entry.Name(), err) + } + infos = append(infos, info) + } + return infos, nil +} + +// Readdirnames reads the names of the entries in the directory. +func (f *bridgeFile) Readdirnames(count int) ([]string, error) { + d, ok := f.file.(fs.ReadDirFile) + if !ok { + return nil, &os.PathError{Op: "readdirnames", Path: f.name, Err: ErrNotDir} + } + + entries, err := d.ReadDir(count) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + return names, nil +} + +// Sync is generally a no-op for a read-only file, but we pass it through if the underlying file supports it. +func (f *bridgeFile) Sync() error { + if s, ok := f.file.(interface{ Sync() error }); ok { + return s.Sync() + } + return nil +} + +// Lock and Unlock are not supported by io/fs. +func (f *bridgeFile) Lock() error { return ErrNoLock } +func (f *bridgeFile) Unlock() error { return ErrNoLock } + +// --- Write operations for the file (disabled) --- + +func (f *bridgeFile) Write(p []byte) (int, error) { return 0, errReadOnlyBridge } +func (f *bridgeFile) WriteAt(p []byte, off int64) (int, error) { return 0, errReadOnlyBridge } +func (f *bridgeFile) Truncate(size int64) error { return errReadOnlyBridge } +func (f *bridgeFile) WriteString(s string) (int, error) { return 0, errReadOnlyBridge } +func (f *bridgeFile) Chmod(mode os.FileMode) error { return errReadOnlyBridge } +func (f *bridgeFile) Chown(uid, gid int) error { return errReadOnlyBridge } diff --git a/iofsbridge_test.go b/iofsbridge_test.go new file mode 100644 index 00000000..1f9cd657 --- /dev/null +++ b/iofsbridge_test.go @@ -0,0 +1,432 @@ +package afero_test + +import ( + "errors" + "io" + "io/fs" + "os" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/spf13/afero" +) + +// Setup the mock io/fs.FS for testing using Go's standard testing FS. +func setupTestBridgeFS() fs.FS { + return fstest.MapFS{ + "file.txt": {Data: []byte("hello world"), Mode: 0444}, + "dir/nested.txt": {Data: []byte("nested content"), Mode: 0444}, + "empty_dir": {Mode: fs.ModeDir | 0555}, + } +} + +func TestBridgeIOFS_Read(t *testing.T) { + afs := afero.NewBridgeIOFS(setupTestBridgeFS()) + + t.Run("Name", func(t *testing.T) { + if !strings.Contains(afs.Name(), "fstest.MapFS") { + t.Errorf("Expected Name() to contain 'fstest.MapFS', got %s", afs.Name()) + } + }) + + t.Run("ReadFile", func(t *testing.T) { + content, err := afero.ReadFile(afs, "file.txt") + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(content) != "hello world" { + t.Errorf("Expected 'hello world', got '%s'", string(content)) + } + }) + + t.Run("Open and FileName", func(t *testing.T) { + file, err := afs.Open("dir/nested.txt") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + defer file.Close() + if file.Name() != "dir/nested.txt" { + t.Errorf("Expected file name 'dir/nested.txt', got '%s'", file.Name()) + } + }) + + t.Run("Stat", func(t *testing.T) { + info, err := afs.Stat("file.txt") + if err != nil { + t.Fatalf("Stat file.txt failed: %v", err) + } + if info.Size() != int64(len("hello world")) { + t.Errorf("Expected size %d, got %d", len("hello world"), info.Size()) + } + if info.IsDir() { + t.Error("Expected file.txt not to be a directory") + } + + info, err = afs.Stat("dir") + if err != nil { + t.Fatalf("Stat dir failed: %v", err) + } + if !info.IsDir() { + t.Error("Expected dir to be a directory") + } + }) + + t.Run("FileNotFound", func(t *testing.T) { + _, err := afs.Open("nonexistent.txt") + if !os.IsNotExist(err) { + t.Errorf("Expected ErrNotExist, got %v", err) + } + }) +} + +func TestBridgeIOFS_ReadDir(t *testing.T) { + afs := afero.NewBridgeIOFS(setupTestBridgeFS()) + + t.Run("Root directory", func(t *testing.T) { + entries, err := afero.ReadDir(afs, ".") + if err != nil { + t.Fatalf("ReadDir failed: %v", err) + } + + // Expecting "file.txt", "dir", "empty_dir" (MapFS returns sorted results) + if len(entries) != 3 { + t.Fatalf("Expected 3 entries, got %d", len(entries)) + } + if entries[0].Name() != "dir" { + t.Errorf("Expected entry 0 to be 'dir', got %s", entries[0].Name()) + } + if entries[1].Name() != "empty_dir" { + t.Errorf("Expected entry 1 to be 'empty_dir', got %s", entries[1].Name()) + } + if entries[2].Name() != "file.txt" { + t.Errorf("Expected entry 2 to be 'file.txt', got %s", entries[2].Name()) + } + }) + + t.Run("ReadDir on file", func(t *testing.T) { + f, err := afs.Open("file.txt") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + defer f.Close() + + // Attempting Readdir on a file should return ErrNotDir + _, err = f.Readdir(0) + if !errors.Is(err, afero.ErrNotDir) { + t.Errorf("Expected afero.ErrNotDir, got %v", err) + } + }) +} + +func TestBridgeIOFS_WriteOperations(t *testing.T) { + afs := afero.NewBridgeIOFS(setupTestBridgeFS()) + + // Helper to check if the error wraps os.ErrPermission (which the internal errReadOnlyBridge does) + checkReadOnlyError := func(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Error("Expected an error, got nil") + return + } + // We check if it wraps os.ErrPermission, as the specific internal error isn't exposed. + if !os.IsPermission(err) { + t.Errorf("Expected error to wrap os.ErrPermission (indicating read-only), got %v", err) + } + } + + t.Run("Filesystem modifications", func(t *testing.T) { + checkReadOnlyError(t, afs.Mkdir("newdir", 0755)) + checkReadOnlyError(t, afs.Remove("file.txt")) + checkReadOnlyError(t, afs.Rename("file.txt", "new.txt")) + checkReadOnlyError(t, afs.Chown("file.txt", 0, 0)) + }) + + t.Run("OpenFile with write flags", func(t *testing.T) { + // O_RDONLY should succeed + f, err := afs.OpenFile("file.txt", os.O_RDONLY, 0) + if err != nil { + t.Fatalf("OpenFile O_RDONLY failed: %v", err) + } + f.Close() + + // Write flags should fail + _, err = afs.OpenFile("file.txt", os.O_RDWR, 0644) + checkReadOnlyError(t, err) + _, err = afs.OpenFile("newfile.txt", os.O_CREATE|os.O_RDWR, 0644) + checkReadOnlyError(t, err) + }) + + t.Run("Write to opened file", func(t *testing.T) { + f, err := afs.Open("file.txt") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + defer f.Close() + + _, err = f.Write([]byte("test")) + checkReadOnlyError(t, err) + err = f.Truncate(0) + checkReadOnlyError(t, err) + }) +} + +// TestComposition verifies that the BridgeIOFS can be used as a base layer in a Union FS. +func TestBridgeIOFS_Composition(t *testing.T) { + // 1. Base layer: BridgeIOFS (read-only) + baseFs := afero.NewBridgeIOFS(setupTestBridgeFS()) + + // 2. Overlay layer: Writable memory FS + overlayFs := afero.NewMemMapFs() + afero.WriteFile(overlayFs, "overlay.txt", []byte("overlay data"), 0644) + + // 3. Union FS (CopyOnWrite) + unionFs := afero.NewCopyOnWriteFs(baseFs, overlayFs) + + t.Run("Read from base", func(t *testing.T) { + // Should read "dir/nested.txt" which only exists in the base + content, err := afero.ReadFile(unionFs, "dir/nested.txt") + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(content) != "nested content" { + t.Errorf("Expected 'nested content', got '%s'", string(content)) + } + }) + + t.Run("Write new file", func(t *testing.T) { + err := afero.WriteFile(unionFs, "new_union.txt", []byte("union data"), 0644) + if err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // Verify it exists in the overlay, not the base + exists, _ := afero.Exists(overlayFs, "new_union.txt") + if !exists { + t.Error("New file should exist in overlay") + } + exists, _ = afero.Exists(baseFs, "new_union.txt") + if exists { + t.Error("New file should not exist in base") + } + }) + + t.Run("Overwrite base file (Copy-on-Write)", func(t *testing.T) { + // Modify file.txt, triggering copy-on-write + err := afero.WriteFile(unionFs, "file.txt", []byte("modified"), 0644) + if err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // Base should remain unchanged + content, _ := afero.ReadFile(baseFs, "file.txt") + if string(content) != "hello world" { + t.Errorf("Base FS content changed. Expected 'hello world', got '%s'", string(content)) + } + + // Overlay and Union should have the new content + content, _ = afero.ReadFile(overlayFs, "file.txt") + if string(content) != "modified" { + t.Errorf("Overlay FS content incorrect. Expected 'modified', got '%s'", string(content)) + } + }) +} + +// TestFSTestCompliance uses the standard library's fstest.TestFS. +func TestBridgeIOFS_FSTestCompliance(t *testing.T) { + afs := afero.NewBridgeIOFS(setupTestBridgeFS()) + + // Afero provides an adapter to go from afero.Fs back to io/fs.FS (afero.NewIOFS) + // This tests the round-trip (io/fs -> BridgeIOFS -> IOFS adapter) ensuring compliance. + iofs := afero.NewIOFS(afs) + + // Run the standard compliance test suite + err := fstest.TestFS(iofs, "file.txt", "dir/nested.txt", "empty_dir") + if err != nil { + t.Errorf("BridgeIOFS failed standard io/fs compliance tests: %v", err) + } +} + +// --- Advanced Interface Tests (Seek, ReadAt) --- +// We need mocks because fstest.MapFS does not guarantee Seek or ReadAt support. + +// BridgeMockFileInfo implements fs.FileInfo. +type BridgeMockFileInfo struct { + name string + size int64 + mode fs.FileMode +} + +func (m BridgeMockFileInfo) Name() string { return m.name } +func (m BridgeMockFileInfo) Size() int64 { return m.size } +func (m BridgeMockFileInfo) Mode() fs.FileMode { return m.mode } +func (m BridgeMockFileInfo) ModTime() time.Time { return time.Now() } +func (m BridgeMockFileInfo) IsDir() bool { return m.mode.IsDir() } +func (m BridgeMockFileInfo) Sys() interface{} { return nil } + +// BridgeSeekableFile implements fs.File, io.Seeker, and io.ReaderAt. +type BridgeSeekableFile struct { + data []byte + name string + offset int64 +} + +func (sf *BridgeSeekableFile) Stat() (fs.FileInfo, error) { + return BridgeMockFileInfo{name: sf.name, size: int64(len(sf.data)), mode: 0444}, nil +} +func (sf *BridgeSeekableFile) Close() error { return nil } +func (sf *BridgeSeekableFile) Read(p []byte) (int, error) { + if sf.offset >= int64(len(sf.data)) { + return 0, io.EOF + } + n := copy(p, sf.data[sf.offset:]) + sf.offset += int64(n) + return n, nil +} +func (sf *BridgeSeekableFile) Seek(offset int64, whence int) (int64, error) { + var newOffset int64 + switch whence { + case io.SeekStart: + newOffset = offset + case io.SeekCurrent: + newOffset = sf.offset + offset + case io.SeekEnd: + newOffset = int64(len(sf.data)) + offset + } + if newOffset < 0 { + return 0, errors.New("invalid seek offset") + } + sf.offset = newOffset + return sf.offset, nil +} +func (sf *BridgeSeekableFile) ReadAt(p []byte, off int64) (int, error) { + if off < 0 { + return 0, errors.New("invalid offset") + } + if off >= int64(len(sf.data)) { + return 0, io.EOF + } + n := copy(p, sf.data[off:]) + if n < len(p) { + // If we copied less than requested, it means we hit the end. + return n, io.EOF + } + return n, nil +} + +// BridgeSeekableFS implements fs.FS and returns a BridgeSeekableFile. +type BridgeSeekableFS struct{ file *BridgeSeekableFile } + +func (sfs *BridgeSeekableFS) Open(name string) (fs.File, error) { + // io/fs paths use forward slashes and dot cleaning. + if name == sfs.file.name { + sfs.file.offset = 0 // Reset offset on open + return sfs.file, nil + } + return nil, fs.ErrNotExist +} + +func TestBridgeIOFS_InterfacesSupported(t *testing.T) { + // Test if Seek and ReadAt are correctly passed through if the underlying io/fs.File supports them. + sfs := &BridgeSeekableFS{ + file: &BridgeSeekableFile{ + data: []byte("0123456789"), + name: "data.bin", + }, + } + + afs := afero.NewBridgeIOFS(sfs) + f, err := afs.Open("data.bin") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + defer f.Close() + + // Run sequentially as the file offset state is shared. + t.Run("Seek", func(t *testing.T) { + pos, err := f.Seek(5, io.SeekStart) + if err != nil { + t.Fatalf("Seek failed: %v", err) + } + if pos != 5 { + t.Errorf("Expected position 5, got %d", pos) + } + + buf := make([]byte, 3) + _, err = f.Read(buf) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if string(buf) != "567" { + t.Errorf("Expected '567', got '%s'", string(buf)) + } + // Current position is now 8 + }) + + t.Run("ReadAt", func(t *testing.T) { + buf := make([]byte, 4) + // ReadAt should not affect the current seek position (which is 8 from the previous test) + n, err := f.ReadAt(buf, 2) + if err != nil && err != io.EOF { + t.Fatalf("ReadAt failed: %v", err) + } + if n != 4 { + t.Errorf("Expected to read 4 bytes, got %d", n) + } + if string(buf) != "2345" { + t.Errorf("Expected '2345', got '%s'", string(buf)) + } + + // Verify seek position is unchanged (should still be 8) + buf = make([]byte, 2) + n, err = f.Read(buf) + // We expect EOF or nil depending on how exactly the underlying Read handles the end. + if err != nil && err != io.EOF { + t.Fatalf("Read failed: %v", err) + } + if n != 2 { + t.Errorf("Expected to read 2 bytes, got %d", n) + } + if string(buf) != "89" { + t.Errorf("Expected '89', got '%s'", string(buf)) + } + }) +} + +func TestBridgeIOFS_InterfacesNotSupported(t *testing.T) { + // fstest.MapFS files generally do not implement Seek or ReadAt + afs := afero.NewBridgeIOFS(setupTestBridgeFS()) + + f, err := afs.Open("file.txt") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + defer f.Close() + + t.Run("Seek Fails", func(t *testing.T) { + _, err = f.Seek(5, io.SeekStart) + // Check that the specific Afero error is returned + if !errors.Is(err, afero.ErrNoSeek) { + t.Errorf("Expected afero.ErrNoSeek, got %v", err) + } + // Check that it is wrapped in PathError + var pe *os.PathError + if !errors.As(err, &pe) { + t.Errorf("Expected error to be a PathError, got %T", err) + } + }) + + t.Run("ReadAt Fails", func(t *testing.T) { + _, err = f.ReadAt([]byte{1}, 5) + // Check that the specific Afero error is returned + if !errors.Is(err, afero.ErrNoReadAt) { + t.Errorf("Expected afero.ErrNoReadAt, got %v", err) + } + // Check that it is wrapped in PathError + var pe *os.PathError + if !errors.As(err, &pe) { + t.Errorf("Expected error to be a PathError, got %T", err) + } + }) +} From 68e9c69bc510ab21af4672779e18f331ac2064ed Mon Sep 17 00:00:00 2001 From: spf13 Date: Thu, 7 May 2026 14:50:03 -0400 Subject: [PATCH 2/2] iofsbridge: fix three test failures - Use errors.Is(err, os.ErrPermission) instead of os.IsPermission(): the latter only unwraps *PathError/*LinkError/*SyscallError, not fmt.Errorf- wrapped errors, so the existing check always returned false. - Skip TestBridgeIOFS_FSTestCompliance on Windows: afero.IOFS.Glob uses filepath.Match which on Windows treats backslash as a path separator and rejects the backslash-escaped character-class patterns (e.g. [\t]) that fstest.TestFS generates. This is a pre-existing IOFS adapter limitation, consistent with how the existing TestIOFS is skipped. - Fix TestBridgeIOFS_InterfacesNotSupported: fstest.MapFS files embed a bytes.Reader which implements io.Seeker and io.ReaderAt, so the test assumption that they don't was wrong. Add BridgeBasicFile/BridgeNonSeekableFS mocks that explicitly omit those interfaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- iofsbridge_test.go | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/iofsbridge_test.go b/iofsbridge_test.go index 1f9cd657..94d20c94 100644 --- a/iofsbridge_test.go +++ b/iofsbridge_test.go @@ -5,6 +5,7 @@ import ( "io" "io/fs" "os" + "runtime" "strings" "testing" "testing/fstest" @@ -131,7 +132,7 @@ func TestBridgeIOFS_WriteOperations(t *testing.T) { return } // We check if it wraps os.ErrPermission, as the specific internal error isn't exposed. - if !os.IsPermission(err) { + if !errors.Is(err, os.ErrPermission) { t.Errorf("Expected error to wrap os.ErrPermission (indicating read-only), got %v", err) } } @@ -235,6 +236,13 @@ func TestBridgeIOFS_Composition(t *testing.T) { // TestFSTestCompliance uses the standard library's fstest.TestFS. func TestBridgeIOFS_FSTestCompliance(t *testing.T) { + if runtime.GOOS == "windows" { + // afero.IOFS.Glob uses filepath.Match which on Windows treats \ as a path + // separator and rejects backslash-escaped character classes (e.g. [\t]) + // that fstest.TestFS generates. This is a pre-existing limitation of the + // IOFS adapter, not a defect in BridgeIOFS itself. + t.Skip("Skipping on Windows: filepath.Match rejects fstest.TestFS glob patterns") + } afs := afero.NewBridgeIOFS(setupTestBridgeFS()) // Afero provides an adapter to go from afero.Fs back to io/fs.FS (afero.NewIOFS) @@ -394,9 +402,35 @@ func TestBridgeIOFS_InterfacesSupported(t *testing.T) { }) } +// BridgeBasicFile implements fs.File without Seek or ReadAt. +// Used to test that BridgeIOFS correctly returns ErrNoSeek/ErrNoReadAt when +// the underlying fs.File does not support those interfaces. +type BridgeBasicFile struct { + r io.Reader + name string +} + +func (f *BridgeBasicFile) Read(p []byte) (int, error) { return f.r.Read(p) } +func (f *BridgeBasicFile) Close() error { return nil } +func (f *BridgeBasicFile) Stat() (fs.FileInfo, error) { + return BridgeMockFileInfo{name: f.name, size: 5, mode: 0444}, nil +} + +// BridgeNonSeekableFS returns files that do not implement io.Seeker or io.ReaderAt. +type BridgeNonSeekableFS struct{} + +func (nsfs *BridgeNonSeekableFS) Open(name string) (fs.File, error) { + if name == "file.txt" { + return &BridgeBasicFile{r: strings.NewReader("hello"), name: "file.txt"}, nil + } + return nil, fs.ErrNotExist +} + func TestBridgeIOFS_InterfacesNotSupported(t *testing.T) { - // fstest.MapFS files generally do not implement Seek or ReadAt - afs := afero.NewBridgeIOFS(setupTestBridgeFS()) + // Use a backend whose files explicitly do not implement Seek or ReadAt. + // fstest.MapFS files embed a bytes.Reader which does implement those + // interfaces, so it cannot be used here. + afs := afero.NewBridgeIOFS(&BridgeNonSeekableFS{}) f, err := afs.Open("file.txt") if err != nil {