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.
Mkdiron a path that is already a directory returnsnil. - An existing file is still an error. The message comes from the backend and is not
normalised:
memfsreportsfile already exists "/path",osfsreportsmkdir /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,osfsand thechroothelper all implement it, so this is the normal path. - Backend does not — the call returns
niland 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.OpenFilereturnsis a directoryforO_RDWRandfile existsforO_EXCL. The failure is deferred to the first byte-level operation instead. - The listing is a snapshot. It is taken on the first
Readdircall 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.
Symlinks¶
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.
Related¶
- API reference —
New,Option,WithLocker. - billy backend support — what each backend honours.
- billy vs afero semantics — why the divergences were resolved this way.