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 symlink argument-order trap¶
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.
Related¶
- What this does not do — the limits, and why each one is there.
- Filesystem operations — every method and what it returns.
- Make a handle concurrency-safe
- Work with a go-git worktree