Skip to content

Filesystem operations

Every method on the returned afero.Fs and on the handles it hands out: what it maps onto, what it returns, and where it diverges from os or afero.OsFs. If an operation is behaving in a way you did not expect, it is described here.

Behaviour that depends on which billy backend you wrapped is on billy backend support. Behaviour that is the same everywhere is here.

afero.Fs operations

Method Maps onto Divergence worth knowing
Create(name) billy.Create Missing parent directories are created implicitly.
Open(name) OpenFile(name, os.O_RDONLY, 0) On a directory, returns a synthesised directory handle instead of failing.
OpenFile(name, flag, perm) Stat first, then billy.OpenFile If name is an existing directory the flag and perm are ignored and a read-only directory handle is returned.
Mkdir(name, perm) billy.MkdirAll Creates parents; succeeds if the directory already exists.
MkdirAll(path, perm) billy.MkdirAll Identical to Mkdir.
Remove(name) billy.Remove
RemoveAll(path) go-billy/v5/util.RemoveAll billy has no RemoveAll of its own.
Rename(old, new) billy.Rename
Stat(name) billy.Stat Follows symlinks.
Name() bfs.Root(), cached See Name() below.
Chmod(name, mode) billy.Chmod when the backend implements it Silently does nothing when it does not. See Chmod.
Chown(name, uid, gid) nothing Always returns nil.
Chtimes(name, atime, mtime) nothing Always returns nil.
LstatIfPossible(name) billy.Lstat The bool is always true.
SymlinkIfPossible(old, new) billy.Symlink(target, link) Argument order is preserved; see symlinks.
ReadlinkIfPossible(name) billy.Readlink

Mkdir does not fail the way afero's does

Mkdir and MkdirAll are the same call. billy has no plain Mkdir, so both map onto MkdirAll. Three consequences:

  • A missing parent is created, rather than returning an error.
  • An existing directory is not an error. Mkdir on a path that is already a directory returns nil.
  • An existing file is still an error. The message comes from the backend and is not normalised: memfs reports file already exists "/path", osfs reports mkdir /abs/path: not a directory.

Code that uses a failing Mkdir as an existence test will not get the failure. Use Stat instead. The reasoning behind mapping rather than emulating is in billy vs afero semantics.

The perm argument is passed to billy, but whether it reaches the directory depends entirely on the backend — see directory permissions.

Missing parent directories are created on write

Any open with os.O_CREATE — including every afero.WriteFile and Fs.Create — creates the parent directories it needs. This holds for memfs, osfs and osfs with WithBoundOS().

os.Create and afero.OsFs both fail with no such file or directory in that case, so code ported onto this adapter loses an error it may have been relying on. There is no option to restore the strict behaviour; check the parent with Stat first if you need it.

Chmod

Chmod type-asserts the wrapped filesystem to billy.Chmod:

  • Backend implements billy.Chmod — the call is delegated and the result, including any error, is returned. memfs, osfs and the chroot helper all implement it, so this is the normal path.
  • Backend does not — the call returns nil and nothing happens.

This means Chmod can now return an error, which it could not before v0.1.2. Chmod on a path that does not exist returns the backend's not-found error rather than nil.

Chown and Chtimes are unconditional no-ops returning nil, because go-billy exposes no interface for either operation. There is nothing to delegate to and nothing to detect; billy.Change — the wider interface that adds Chown, Lchown and Chtimes — is implemented by no billy backend.

Name()

Returns bfs.Root(), read once during New and cached. It never takes the lock and never changes.

If the backend reports an empty root the adapter substitutes the literal string aferobilly. In practice this rarely fires: memfs.New() reports /, and osfs.New(dir) reports dir, because both wrap themselves in billy's chroot helper, whose Root() returns the base path it was constructed with. The fallback exists for custom billy.Filesystem implementations that return "".

Directory handles

billy cannot Open a directory. When Open/OpenFile finds that the path stats as a directory, it returns a synthesised handle instead.

Operation on a directory handle Result
Readdir(n) / Readdirnames(n) Work — see pagination
Stat() Works, delegates to billy.Stat
Name() The path as passed to Open
Close() / Sync() Return nil
Read, ReadAt, Write, WriteAt, WriteString, Seek, Truncate *os.PathError whose Err is is a directory, matching os

Two divergences from os:

  • The flags are ignored. OpenFile(dir, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644) succeeds and returns a read-only directory handle. os.OpenFile returns is a directory for O_RDWR and file exists for O_EXCL. The failure is deferred to the first byte-level operation instead.
  • The listing is a snapshot. It is taken on the first Readdir call and never refreshed, so entries created after that call do not appear in that handle. Reopen the directory to see them.

How Readdir pagination works

The full listing is fetched once from billy.ReadDir and then paginated to match os.File:

Call Returns
Readdir(n) with n <= 0 Every remaining entry, and nil error — including an empty slice once exhausted
Readdir(n) with n > 0 Up to n entries
Readdir(n) with n > 0, nothing left nil, io.EOF

Readdirnames calls Readdir and maps to names, so it shares the same offset and the same io.EOF behaviour.

On a regular file, Readdir and Readdirnames return an *os.PathError whose Err is not a directory. billy's own ReadDir returns an empty list there, so the adapter synthesises the error os and afero callers expect.

Regular file handles

Method Behaviour
Read, Seek, Truncate, Close Delegated to the billy file
ReadAt Delegated — billy.File requires io.ReaderAt, so this always works
Write, WriteString Delegated (WriteString calls Write)
WriteAt See below
Stat Re-stats by path, not by handle — see below
Sync Always returns nil without flushing anything
Name The path as passed to Open, not the backend's idea of it
Readdir, Readdirnames *os.PathError, not a directory

WriteAt is usually emulated

WriteAt uses the file's native io.WriterAt if it has one, and otherwise emulates it: save the current offset, seek to off, write, seek back. The emulation preserves the seek offset, and if the restoring seek fails the write's own result still wins.

In practice the emulation is the path you get. billy.File does not require io.WriterAt (upstream has it commented out with a TODO: Add io.WriterAt for v6), and both memfs.New() and osfs.New(dir) return files wrapped by billy's chroot helper, whose wrapper type embeds the billy.File interface — which erases WriteAt from the method set even when the file underneath has one. Only osfs.New(dir, osfs.WithBoundOS()) returns an unwrapped file that satisfies io.WriterAt.

The observable behaviour is the same either way. The cost is that a WriteAt is three backend calls rather than one, and that it is not atomic with respect to the file's own offset unless the adapter's locker is held across it — which it is, since WriteAt takes the lock for the whole sequence.

Stat on an open handle is a path lookup

File.Stat() calls billy.Stat(name) using the path the file was opened with, rather than asking the open handle. os.File.Stat uses the file descriptor. So if the file is renamed or removed while your handle is open, Stat() fails with the backend's not-found error even though the handle is still perfectly usable for reading and writing.

Sync never flushes

File.Sync() returns nil without doing anything, on every backend. billy.File declares no Sync, so there is nothing in the interface to delegate to. Do not treat a successful Sync() through this adapter as a durability guarantee — see what this does not do.

The two interfaces name the arguments in opposite orders and the adapter maps between them:

// afero
SymlinkIfPossible(oldname, newname string) error
// billy
Symlink(target, link string) error

oldname is the target; newname is the link that gets created. Getting this backwards produces a symlink pointing the wrong way, which is easy to miss in review.

LstatIfPossible returns (os.FileInfo, bool, error) where the bool means "Lstat was used". It is always true, because billy.Filesystem embeds billy.Symlink and so always declares Lstat.

What errors look like

Errors are passed through from billy unchanged. The adapter wraps nothing and normalises nothing, except for the two cases it synthesises itself (is a directory and not a directory, both as *os.PathError).

That has a practical consequence: match with errors.Is, never with a type assertion. The same logical failure arrives as a different concrete type per backend.

Failure memfs osfs
Open a missing file bare os.ErrNotExist (*errors.errorString) *fs.PathError wrapping ENOENT
Rename a missing file bare os.ErrNotExist *os.LinkError
OpenFile with O_EXCL on an existing file bare os.ErrExist *fs.PathError wrapping EEXIST
Remove a non-empty directory dir: /path contains files *fs.PathError, directory not empty

errors.Is(err, fs.ErrNotExist) and errors.Is(err, fs.ErrExist) hold in every row above where they should. A err.(*fs.PathError) assertion succeeds against osfs and fails against memfs, which is exactly the kind of bug that only shows up in production.

RemoveAll on a path that does not exist returns nil on every backend, matching os.RemoveAll.