Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
109 changes: 90 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand All @@ -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")
}
```
Expand Down
171 changes: 171 additions & 0 deletions iofsbridge.go
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 15 in iofsbridge.go

View workflow job for this annotation

GitHub Actions / Lint

File is not properly formatted (gofumpt)
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 }
Loading
Loading