Skip to content

billy vs afero semantics

billy and afero are mostly one-to-one, which is what makes the adapter small. The value is in the handful of places they disagree — this page documents every one and why it was resolved the way it was. If something behaves unexpectedly, the answer is almost certainly here.

The mismatches

afero expects What the adapter does
Mkdir(name, perm) Maps to billy's MkdirAll — creates parents, and succeeds on a path that is already a directory, rather than afero's strict "fail on a missing parent or an existing target". A path that is already a file still fails
RemoveAll(path) Uses go-billy/v5/util.RemoveAll — billy has no RemoveAll of its own
Chmod(name, mode) Delegated to the backend when it implements billy.Chmod — which memfs, osfs and chroot all do — and a silent no-op when it does not
Chown / Chtimes No-ops returning nil. go-billy exposes no interface for either, so there is nothing to delegate to
Open(dir) billy cannot open a directory, so a synthesised read-only directory handle is returned: Readdir and Stat work; byte-level operations return an *os.PathError reading "is a directory"
File.Readdir(count) Snapshots billy's ReadDir once, then paginates to os.File semantics: count <= 0 returns everything; count > 0 returns up to count and then io.EOF; on a regular file it returns "not a directory"
File.ReadAt Passed straight through — billy.File guarantees io.ReaderAt
File.WriteAt Uses the native io.WriterAt when the file provides one; with memfs and osfs it does not, so the usual path is a seek-based emulation that restores the original offset afterwards
Symlinks Lstater, Symlinker, Linker and LinkReader delegate to billy's Lstat/Symlink/Readlink
Name() The backing filesystem's root (bfs.Root()), resolved once at construction; falls back to "aferobilly" when the backend reports an empty root

Why Mkdir creates parents

This is the mismatch most likely to surprise, and it was a deliberate reversal of an earlier decision to emulate afero strictly.

billy has no plain Mkdir — only MkdirAll. Faithfully reproducing afero's semantics would mean the adapter checking for a missing parent and an existing target itself, then synthesising the errors afero would have returned: more code, more ways to be subtly wrong, and a race between the check and the create.

Mapping straight onto MkdirAll is simpler, matches what billy actually does, and suits the workloads this adapter exists for (building and editing trees). The cost is that code relying on Mkdir failing — as an existence test, or to catch a missing parent — will not get that failure. In practice, code doing that should be using Stat.

Why Chmod is delegated but Chown and Chtimes are not

The three used to be treated the same way — all silent no-ops — on the reasoning that a billy backend would ignore modes anyway. That reasoning turned out to be wrong about Chmod, and it was changed in v0.1.2.

The case that broke it is git. go-git derives a committed blob's mode — 100755 for an executable, 100644 otherwise — from the mode of the file in the worktree. A scaffolder that writes a shell script through the adapter and then chmods it executable was producing a 100644 blob, because the chmod went nowhere. The file was right and the commit was wrong, which is a hard failure to trace back to a no-op in a filesystem adapter.

So Chmod now type-asserts the backend to billy.Chmod and delegates when it can. billy.Chmod is the narrow interface declaring only Chmod, and memfs, osfs and the chroot helper all satisfy it, so in practice the mode is honoured.

Chown and Chtimes could not follow, because there is nothing to follow to. The only billy interface carrying them is billy.Change, which bundles Chmod, Chown, Lchown and Chtimes together — and no backend in go-billy implements it. Asserting to billy.Change would always fail and change nothing.

The fallback for a backend without billy.Chmod is still a silent nil rather than an error, and that part of the original reasoning stands. Plenty of correct code sets permissions as a matter of course after writing a file; failing those calls would break otherwise-working callers to report something that was never going to work anyway.

The cost of the change is that Chmod can now return an error where it previously could not — a chmod on a missing path now reports the backend's not-found error. Callers that ignored the return value are unaffected; callers that check it may see a failure they never saw before.

Why directories get a synthesised handle

billy simply cannot Open a directory, but afero callers — and anything built on afero.Walk or afero.ReadDir — expect a directory to open and answer Readdir. So the adapter fabricates a read-only handle backed by a one-shot ReadDir snapshot.

Two consequences worth knowing. Byte-level operations on it fail with a proper *os.PathError ("is a directory"), matching os. And because the listing is snapshotted when the handle is opened, entries created afterwards will not appear in that handle's Readdir — reopen to see them.

The two interfaces name symlink arguments in opposite orders:

  • billy: Symlink(target, link)
  • afero: SymlinkIfPossible(oldname, newname)

They mean the same thing, in the opposite order. The adapter maps between them, and it is called out here because it is exactly the kind of mismatch that produces a symlink pointing the wrong way in a way that is easy to miss in review.

Why errors are not normalised between backends

An error from the backend is returned exactly as it arrived, which means the same logical failure has a different concrete type depending on what you wrapped: opening a missing file gives a bare os.ErrNotExist on memfs and an *fs.PathError on osfs.

Normalising them would mean inspecting every error and rewrapping it in whatever os would have produced. That loses the backend's own detail, and it puts the adapter in the business of guessing what a backend meant by an error it has never seen. Passing them through keeps the information and keeps the adapter honest about where the failure came from.

The practical rule that follows is in the reference: match with errors.Is, never with a type assertion.

What is not adapted

The adapter presents a filesystem and nothing more — no caching, no path sandboxing, no virtual root, no reverse direction, and no way to reach the billy object underneath.

Each of those is a deliberate limit with a reason, and they are set out together in what this does not do.