config-billy¶
Problem¶
The filesystem adapters umbrella establishes the family of
config-<fs> sibling modules and settles the decisions that cut across all of them, but by D2 it
deliberately specifies no single adapter. This is that per-adapter spec for config-billy, a
Phase 2 POSIX-shaped adapter (umbrella Implementation phases) that wraps a
go-billy billy.Filesystem as a read+write
config.FS.
go-billy is the storage-agnostic filesystem abstraction underneath go-git and a large family of
Git tooling; a tool built on it already holds a billy.Filesystem — a real on-disk tree
(osfs), an in-memory one (memfs), a chrooted subtree, or its own implementation — and today it
cannot point this module at that filesystem. config-billy is the bridge, and it is the closest
sibling to config-afero: both wrap a
read+write filesystem abstraction a consumer chose, not a specific storage backend, so both are
pure translation of the six config.FS methods with no credentials and no network (umbrella D3).
The whole of the design work is one question the umbrella leaves open (D2): can a
billy.Filesystem honestly report a real operating-system path, so config-billy may implement
config.RealPather and get native fsnotify hot-reload? The answer, verified against the library,
is no — and that is what most of this spec settles.
The contract¶
config.FS is six methods, and every one maps cleanly onto go-billy:
config.FS |
go-billy |
|---|---|
ReadFile |
util.ReadFile(bfs, name) |
WriteFile |
util.WriteFile(bfs, name, data, perm) |
Stat |
bfs.Stat(name) (billy.Basic) |
Rename |
bfs.Rename(old, new) (billy.Basic) |
Remove |
bfs.Remove(name) (billy.Basic) |
MkdirAll |
bfs.MkdirAll(path, perm) (billy.Dir) |
billy.Filesystem is Basic + TempFile + Dir + Symlink + Chroot, so every one of these methods is
part of the mandatory interface — no capability probing is needed for the six. util.ReadFile and
util.WriteFile (package github.com/go-git/go-billy/v5/util, same module) are the idiomatic
whole-file helpers; util.WriteFile creates with perm and truncates otherwise, matching
os.WriteFile and this module's config.OS().
Decisions¶
D1 — config-billy, package configbilly, read+write, Wrap(billy.Filesystem) config.FS¶
One sibling module (umbrella D1), a bare vendor-neutral name (go-billy names no cloud). The public
surface is a single constructor mirroring configafero.Wrap and the API shape the umbrella prints:
The result is passed to WithFiles/NewFileBackend exactly as config.OS() is. It is
read+write (umbrella D4): a billy.Filesystem mandates the write methods, so all six work and
none returns config.ErrReadOnlyFS in the ordinary writable case. (A billy filesystem mounted
read-only is caught by inspecting billy.Capabilities(): a missing WriteCapability makes the
write methods return config.ErrReadOnlyFS up front — see Resolved (2026-07-22), item 4.)
D2 — Map the six methods through util, staging and rename done by the Store¶
config-billy translates the six calls and nothing more. A write is committed the way the whole
module commits one — the Store stages content beside the target and Renames over it, through the
config.FS methods — so the adapter needs no staging logic of its own. It maps WriteFile to
util.WriteFile and Rename to billy's native Rename — a real filesystem-level rename (the
umbrella's rename primitive, R2), never a copy-then-delete emulation — and the atomicity is whatever
the wrapped filesystem gives: on osfs a same-directory Rename is os.Rename (atomic on POSIX);
on memfs it is an in-process map swap (atomic within the process). Either way a reader never
observes a half-written file, which is the contract's promise (fs.go, Rename doc). The
overwrite semantics of Rename — whether renaming over an existing target succeeds or errors —
vary by billy backend, so the README documents the assumption (the Store stages beside the target
and renames over it, which osfs and memfs both honour).
go-billy has a TempFile(dir, prefix) method, but config.FS has no TempFile and the Store
builds the staging path itself, so the adapter does not use it — noted only so a future reader knows
it was considered and is available if the Store's staging model ever changes.
D3 — Missing files satisfy errors.Is(err, fs.ErrNotExist), verified¶
The load-bearing half of ReadFile's contract (fs.go) is that an absent file returns an error
satisfying errors.Is(err, fs.ErrNotExist) — the Store treats a missing overlay as normal and a
missing base as fatal, and only that distinction lets it. This was verified by execution against
both backends: util.ReadFile and Stat on a missing name return, respectively,
open …: no such file or directory (osfs) and file does not exist (memfs), and
errors.Is(err, fs.ErrNotExist) is true for both. No error translation is needed in the adapter.
D4 — No config.RealPather: go-billy cannot honestly report an OS path through its interface¶
This is the central decision. config-afero claims config.RealPather by type-switching on the
concrete afero types that genuinely have OS paths (*afero.OsFs, *afero.BasePathFs). The
equivalent is not possible through billy.Filesystem, verified against go-billy v5.9.0:
Root()exists on everybilly.Filesystem(it is part of the mandatorybilly.Chrootinterface) — but it returns a logical root, not necessarily an OS path.osfs.New(dir).Root()returns the realdir;memfs.New().Root()returns"/", a real-looking path for files that exist only in memory.Root()therefore cannot be trusted as an OS path.- The concrete type does not distinguish them. Both
osfs.New(dir)(default) andmemfs.New()surface as the same type,*chroot.ChrootHelper. A type switch — the mechanismconfig-aferorelies on — cannot tell a disk-backed billy filesystem from an in-memory one. - Unwrapping does not rescue it.
ChrootHelper.Underlying()returns*polyfill.Polyfillfor both osfs and memfs — another internal wrapper — so recursively unwrapping to find anosfsconcrete type reaches into unexported helper internals and is version-fragile. Only the non-defaultosfs.New(dir, osfs.WithBoundOS())surfaces a distinguishable*osfs.BoundOS; the common paths do not. - Capabilities do not answer it.
billy.Capable.Capabilities()differs between osfs (0b111111) and memfs (0b011111) — but only byLockCapability. The capability flags describe file-I/O features (write/read/seek/truncate/lock), not whether a path is a real OS path.
fs.go is explicit that "an implementation must not satisfy an optional interface it cannot honour",
and that satisfying RealPather unconditionally "makes every filesystem look watchable, so native
notification is selected and the absence of anything to watch is discovered one path at a time." A
billy.Filesystem gives no reliable interface-level signal to distinguish the honourable case, so
config-billy omits RealPather entirely and is polled.
D5 — Hot-reload via the core poll watcher (umbrella D5), no new mechanism¶
Because it claims no real path, a wrapped billy filesystem is watched by the core's pollWatcher
(umbrella D5, verified there against watch.go): Watch re-reads on the existing
WithPollInterval cadence (DefaultPollInterval, 2s) and stays quiet when nothing resolves
differently. This is free and needs no per-adapter watch API. The 2s default is fine for the common
cases — an osfs tree and an in-process memfs both poll cheaply, unlike a billed cloud API — so
config-billy recommends no special interval. Because it wraps a local filesystem (in-memory
memfs or on-disk osfs), not a billed remote store, it does not implement
config.PollIntervalHinter and keeps the responsive default cadence.
D6 — Footprint is exactly one module: github.com/go-git/go-billy/v5¶
Measured with go list -deps (umbrella D7): the production library graph
(configbilly importing billy + billy/v5/util, util being in the same module) adds exactly
one third-party module, github.com/go-git/go-billy/v5, and nothing else. This is asserted by an
allowlist depfootprint test copied from config-afero. go-billy is the "small one" the umbrella
predicts (D7).
The disk-backed osfs sub-package additionally pulls github.com/cyphar/filepath-securejoin and
golang.org/x/sys, but osfs is only reached in tests — and D7 (below) keeps even the test suite
on memfs, which pulls nothing extra — so those never enter the library graph a consumer inherits.
D7 — Tests run against billy's memfs, matching umbrella D8¶
The unit suite drives the adapter against memfs.New() — billy's in-memory filesystem, named as
this adapter's fake by umbrella D8 — needing no disk and no network. memfs exercises every one of
the six methods (read, write, stat, rename-over-target, remove, mkdirall) and preserves the write
perm (verified: a 0o600 write reads back mode -rw-------). A small companion set may wrap an
osfs.New(t.TempDir()) to prove the adapter is agnostic to the concrete billy backend, but the core
coverage — and the negative RealPather assertion — lives on memfs.
Rejected alternatives¶
Claim RealPather and lean on the watcher's os.SameFile guard. The watcher does confirm a
claimed real path with os.SameFile before pointing fsnotify at it and falls back to polling per
path when they disagree (fs.go, RealPather doc). But fs.go calls satisfying the interface
unconditionally exactly the mistake — it discovers the absence of anything to watch "one path at a
time" instead of up front, and config-afero's own suite asserts an in-memory filesystem must not
claim it. config-billy gets the same negative test (D7). See D4.
Unwrap ChrootHelper/Polyfill to sniff for a real osfs underneath. Reaches through two
layers of internal wrappers into unexported state, tied to go-billy's current wrapping and fragile
across versions — for a signal (D4) that even when found only covers the non-default WithBoundOS
construction. Polling is correct, cheap for both osfs and memfs, and version-proof.
Build staging/TempFile logic into the adapter. The Store already stages-and-renames through the
six methods; duplicating it in the adapter would fork the atomicity story. billy.TempFile is left
unused (D2).
A config-git adapter reading a file at a Git ref, folded in here. Out of scope by the umbrella
(Resolved 1): git-at-a-ref was considered and dropped as too niche. config-billy wraps the billy
filesystem, not a git object database.
Public API¶
package configbilly
// Wrap adapts a go-billy filesystem to config.FS. The result reads and writes
// through the wrapped filesystem and is watched by polling — a billy.Filesystem
// cannot honestly report an operating-system path (see the spec's D4), so it does
// not satisfy config.RealPather.
func Wrap(bfs billy.Filesystem) config.FS
Wrap returns a single unexported adapter type satisfying config.FS and only config.FS — no
four-way type ladder as in config-afero, because RealPather is the only optional interface it
declines (D4); config.LinkReader it claims unconditionally, since Readlink is mandatory on every
billy.Filesystem (Resolved (2026-07-22), item 2). The core needs no addition: everything used
(config.FS, the poll watcher) already ships, and config.ErrReadOnlyFS (umbrella Phase 1) is not
returned because the adapter is read+write.
Compile-time assertions mirror config-afero:
Testing strategy¶
- Unit suite on
memfs(D7, umbrella D8): read, write (withpermpreserved), stat, stage-and-Rename-over-target, remove, mkdirall — the same behaviours the module's own suite asserts against anyconfig.FS(umbrella Testing strategy), driven here throughWrap(memfs.New()). - The negative
RealPatherassertion — the reason the module exists rather than being a handful of lines each consumer writes:Wrap(memfs.New())must not satisfyconfig.RealPather(copied in spirit fromconfig-afero'sTestWrap_InMemoryDoesNotClaimRealPaths), and polling must still deliver a change over a filesystem fsnotify cannot see, exercised throughconfig.NewWatcher(wrapped, …).Watch(…). - Missing-file error (D3):
ReadFile/Statof an absent name satisfyerrors.Is(err, fs.ErrNotExist). - Backend-agnostic spot check: a subset run against
osfs.New(t.TempDir())to prove the six methods work identically over the disk-backed billy filesystem. depfootprintallowlist test (D6): the library graph adds onlygithub.com/go-git/go-billy/v5.
testify is a test-only dependency and does not reach consumers, exactly as in config-afero.
Resolved (2026-07-22)¶
All four open questions were answered by the human on 2026-07-22. Each records what was decided and why, so the choice rests on facts rather than intuition; no decision above is renumbered.
-
Opt-in real-path escape hatch — deferred. v0.1.0 ships no
WrapReal/disk-backed opt-in. D4 omitsRealPatherbecausebilly.Filesystemcannot prove an OS path, and polling already works for every billy filesystem today — anosfstree and an in-processmemfsboth poll cheaply (D5). Restoring native fsnotify for the known-on-disk case would mean a second constructor a caller can lie through, and even then the watcher'sos.SameFileguard would confirm the claimed path and degrade a lie back to polling per path — so the escape hatch buys little over the default. Deferrable to a dated revision if a real demand appears; never a silent addition. -
LinkReader— claim it unconditionally.billy.FilesystemmandatesReadlink(via thebilly.Symlinkinterface), soconfig-billysatisfiesconfig.LinkReaderfor every wrapped filesystem — unlike afero, where the claim is conditional. Where the target is not a link,Readlinkreturns an honest negative —memfsa "not a symlink" error,osfsaninvalid argumenterror — and the Store must fall back to using the path as given. This is not assumed: an acceptance test asserts exactly that fallback (a regular file wrapped, written through its plain path) so the claim rests on observed Store behaviour on aReadlinkerror, not on an untested presumption. -
Path convention — documentation, no normalisation.
go-billynames are forward-slash and interpreted relative to the wrapped filesystem's root (anosfs.New("/etc/app")reads"config.yaml", not"/etc/app/config.yaml") — likeconfig.Dir, unlikeconfig.OS(). Names pass throughWrapunchanged: the adapter does not normalise or validate them, and the Store composes paths itself. The README states the relative-to-root convention plainly so a consumer does not pass an absolute OS path expectingconfig.OS()semantics. Documentation is sufficient; silent normalisation would only hide the mismatch it is meant to prevent. -
Runtime-read-only billy filesystems — map to
config.ErrReadOnlyFS.config-billyinspectsbilly.Capabilities()and, whenWriteCapabilityis absent, its write methods returnconfig.ErrReadOnlyFSup front — so from a consumer'serrors.Isperspective a read-only filesystem is a read-only filesystem whether it is read-only by design (config-iofs) or by mount. This deliberately widens the umbrella D4 sentinel's use slightly beyond by-design adapters, for one consistent read-only story across the family rather than a raw underlying error for one case and a sentinel for another. A write to a genuinely writable filesystem that then fails still surfaces the underlying error unchanged — the mapping applies only where the capability is absent, not to every write failure.
No open questions remain.
Implementation phases¶
Phase 0 — this spec, approved 2026-07-22 with all four open questions resolved (Phase 2 of the umbrella).
Phase 1 — the module. Scaffold config-billy from config-afero's layout (go.mod, CI, lint,
justfile, renovate, LICENSE, README skeleton). Implement Wrap and the single six-method adapter
over util.ReadFile/util.WriteFile/Stat/Rename/Remove/MkdirAll (D2). Land the resolved
decisions — unconditional LinkReader, the WriteCapability→config.ErrReadOnlyFS mapping, and
the relative-to-root path convention (Resolved (2026-07-22), items 2–4) — into the code and the
README before merge.
Phase 2 — tests. The memfs unit suite, the negative RealPather assertion, the missing-file
error test, the osfs backend-agnostic spot check, and the depfootprint allowlist (D6, D7).
Phase 3 — docs & release. A README page (bundled into the parent config docs site, page-per-adapter, as the other adapters are), then the v0.1.0 release MR. Any real-path escape hatch (deferred, Resolved (2026-07-22), item 1) is a later dated revision, never a silent addition.