What this does not do¶
Everything here is a deliberate limit, not a missing feature waiting to be filled in. If you came looking for one of these, the answer is no, and the rest of the section says why and what to do instead.
It only bridges one direction¶
billy in, afero out. There is no ToBilly, no reverse adapter, and none is planned here.
If you need an afero.Fs to look like a billy.Filesystem, that is a different module's
job.
The asymmetry is not an oversight. The demand is one-directional: go-git hands out billy
filesystems and application code is written against afero, so the traffic all flows the
same way. A reverse adapter would have to invent the parts of billy that afero has no
answer for — TempFile, Chroot, per-file Lock/Unlock — and would be guessing.
It is not a sandbox¶
The adapter performs no path validation of its own. It does not clean, resolve or reject
anything — .., absolute paths and symlinks that escape the tree are all handed straight
to the backend.
Containment, where you get it, comes from the backend:
chroot.New(fs, base)and the defaultosfs.New(dir)implement a soft chroot. billy says plainly thatChrootHelper"is not a security boundary".osfs.New(dir, osfs.WithBoundOS())enforces the base at the OS boundary and is the one to reach for if containment matters.
Do not treat "I wrapped it in chroot" as protection against hostile paths.
There is no fsync¶
File.Sync() returns nil without flushing anything, on every backend. billy.File
declares no Sync method, so there is nothing in the interface for the adapter to
delegate to.
The consequence is worth stating bluntly: a successful Sync() through this adapter is
not a durability guarantee. Code that writes a file and calls Sync() before reporting
success is, over a disk-backed backend, reporting success on data that may still be in
the page cache. If you need durability, do the write through os on the real path.
There is no caching, no layering, no virtual root¶
The adapter presents a filesystem and nothing else. Every call goes to the backend; there is no read cache, no write buffer, no union or overlay behaviour.
That is not a gap to be filled, because afero already has the pieces and they compose on top:
fs := aferobilly.New(bfs)
ro := afero.NewReadOnlyFs(fs) // refuse writes
base := afero.NewBasePathFs(fs, "/subdir") // a virtual root
cow := afero.NewCopyOnWriteFs(fs, afero.NewMemMapFs())
Building any of that into the adapter would duplicate afero and give you one more thing to configure.
There is no way to reach the billy filesystem underneath¶
Neither the Fs nor its files expose the wrapped billy.Filesystem or billy.File.
There is no Underlying(), no exported struct field, no type assertion that gets you
there.
This one is load-bearing rather than incidental. The adapter's concurrency guarantee is
that every operation passes through the locker. An accessor would be a supported way to
perform an operation that does not, and the guarantee would degrade from "safe by
construction" to "safe if everybody remembers". Removing the accessor removes the
mistake. Keep your own reference to the billy value if you need one — you had it when you
called New.
A locked handle does not make sequences atomic¶
WithLocker serialises individual operations. It does not, and cannot, know that your
read-then-write is meant to be one unit, so another holder of the same lock can act in
the gap between them.
There is no transaction API and no WithFS(func(afero.Fs) error) here. The producer that
owns the mutex is the right place for a callback that holds the lock across a sequence,
because only it knows what a unit of work is. Make a handle
concurrency-safe sets out both patterns and why you should
pick one per handle.
The locker is not reentrant, and the adapter will not save you¶
Using the handle inside a critical section that already holds the same locker deadlocks
the goroutine against itself. The adapter does not detect this, does not use a reentrant
lock, and does not offer a TryLock variant.
A reentrant mutex was the obvious alternative and it is worse. Go's standard library deliberately has none, and simulating one means tracking goroutine identity — which Go does not expose, so it has to be faked. That would trade a deadlock that shows up instantly and reproducibly in development for a subtler failure where a lock is silently re-entered and an invariant the producer relies on quietly breaks. A loud failure at the point of the mistake is the better trade.
Chown and Chtimes will never do anything¶
They return nil on every backend. go-billy has no interface for either: billy.Change
declares Chown, Lchown and Chtimes, and no backend in the library implements it.
Returning an error instead was considered and rejected. Plenty of correct code sets ownership or timestamps as a matter of course after writing a file, and failing those calls would break otherwise-working callers to tell them something that could not have worked anyway. See the reference for what this means per backend.
Note that Chmod is not in this category any more. Since v0.1.2 it delegates to
billy.Chmod where the backend implements it, and can return an error.
It does not normalise errors between backends¶
An error from the backend is returned exactly as it arrived. The same logical failure has
a different concrete type on memfs than on osfs, and the adapter does not paper over
that.
Normalising would mean inspecting and rewrapping every error, which loses information and
puts the adapter in the business of guessing what a backend meant. Matching with
errors.Is works across all of them; the error
surface lists the cases.
It does not give you a snapshot of a commit¶
A live worktree view is what this is. If you want the contents of a particular commit or tree in a separate filesystem, copy the tree out with go-git — a view of a checked-out worktree is the wrong tool, and it will show you whatever is checked out rather than what you asked for.
Related¶
- billy vs afero semantics — the mismatches that are handled, and how.
- billy backend support — what each backend honours.
- Make a handle concurrency-safe