Skip to content

Capability VFS — Testing and evidence ​

Companion to SPEC.md and backends.md. Owns the oracles, the check for every requirement, and the evidence ledger. Delivery order is in PLAN.md.

The trace every requirement needs is requirement -> falsifying scenario -> oracle -> scoped evidence. This page defines six oracles and an allocation check, says what each one is independent of and what it cannot observe, and then lists the check for every requirement.

Oracles at a glance ​

OracleRuns onIndependent ofCannot observe
1. The attack tableevery backend, every legthe implementation: expectations are hand-derivedwhether a refused object was read, on native legs
2. Scripted racesMemVfsreal schedulingreal kernel interleavings
3. Kernel versus walkBlockingVfs on Linux, macOS, Windowsthe component walk's code, and vice versaa bug the kernel and the walk share by design
4. Three-backend differentialall three backendseach backend's effect codea bug in the shared algorithm code
5. Compile-fail rightsthe compilerruntime behaviourrights the OS enforces differently from the type
6. Native legsLinux, macOS, Windows CIthe modelplatforms without a CI leg (FreeBSD and other POSIX)
Allocation instrumentationevery backend@nogc inferenceallocations outside the measured calls

Oracles 3 and 4 answer different questions. Oracle 3 proves the kernel accelerator agrees with the portable floor. Oracle 4 proves the three implementations of the floor agree with each other. A shared defect in the algorithm code passes oracle 4 and is caught only by oracle 1's hand-derived expectations.

A few checks need no oracle: a unit check is a plain unit test of one type or value, and a repository audit is a search or dub describe over the source tree.

Oracle 1: the attack table ​

The table is adapted from Go's rootTestCases (Go os.Root), extended with the policy columns and the mount and Windows rows Go does not have. Every row builds its tree from a declared literal, calls walk (or the named operation) on the root, and asserts the outcome in the column for the active policy. Expectations are derived by hand from SPEC.md, not computed by any production code.

Each tree also contains a sentinel: a directory outside/ beside the root, holding secret. On MemVfs every refusal row additionally asserts that no backend call touched a sentinel node. On native legs it asserts that no handle was returned and that outside/secret's content is unchanged; that a refused row did not read the sentinel is not observable natively, and is proven only on MemVfs.

Policy columns: N = symlinks none, dotDot reject (the default); B = beneath, reject; BI = beneath, inScope; NI = none, inScope. crossMounts is false except where a row says otherwise.

#TreeOperationNNIBBI
1a/b/fwalk("a/b")okokokok
2a/b/fwalk("./a//b/.")okokokok
3—walk("/etc")escapesRootescapesRootescapesRootescapesRoot
4—walk("..")dotDotRefusedescapesRootdotDotRefusedescapesRoot
5a/, c/walk("a/../c")dotDotRefusedok (c)dotDotRefusedok (c)
6a/walk("a/../../x")dotDotRefusedescapesRootdotDotRefusedescapesRoot
7a/b/, s -> awalk("s/b")symlinkRefusedsymlinkRefusedok (a/b)ok (a/b)
8s -> /walk("s")symlinkRefusedsymlinkRefusedescapesRootescapesRoot
9s -> ../outsidewalk("s")symlinkRefusedsymlinkRefusedescapesRootescapesRoot
10chain s1 -> a/s2, a/s2 -> ../b, b/walk("s1")symlinkRefusedsymlinkRefusedok (b)ok (b)
11chain as 10 with a/s2 -> ../../outsidewalk("s1")symlinkRefusedsymlinkRefusedescapesRootescapesRoot
12cycle s1 -> s2, s2 -> s1walk("s1")symlinkRefusedsymlinkRefusedsymlinkLoopsymlinkLoop
13a/b/, a/f/, s -> a/bwalk("s/../f")dotDotRefusedsymlinkRefuseddotDotRefusedok (a/f)
14d/, d/d -> ..walk("d/d")symlinkRefusedsymlinkRefusedok (root)ok (root)
15f (a file)walk("f/x")notADirectorynotADirectorynotADirectorynotADirectory
16a/walk("a/missing")notFoundnotFoundnotFoundnotFound
17m/ on another devicewalk("m")crossesMountcrossesMountcrossesMountcrossesMount
18as 17, crossMounts = truewalk("m")okokokok
1941-link chain inside the rootwalk("s1")symlinkRefusedsymlinkRefusedsymlinkLoopsymlinkLoop
2065 nested directorieswalkAll of themdepthExceededdepthExceededdepthExceededdepthExceeded
21—openDir("a\0b")invalidNameinvalidNameinvalidNameinvalidName
22—openDir of 256 bytesnameTooLongnameTooLongnameTooLongnameTooLong
23s -> outside/secret (a file)openFile("s", read)symlinkRefusedsymlinkRefusedsymlinkRefusedsymlinkRefused
24s -> outside/removeTree("s")ok, link removedok, link removedok, link removedok, link removed
25—symlinkAt("s", "/etc")escapesRootescapesRootescapesRootescapesRoot
26a/symlinkAt("s", "../../outside") on aok, stored verbatimok, stored verbatimok, stored verbatimok, stored verbatim

Rows 25 and 26 check that only absolute link targets are refused (VFO10); on Windows, row 25 also runs with C:\x, \\server\share and \??\C:.

Rows 23 and 24 check that the named entry is never followed (VFO2, VFD2); in row 24 outside/secret must survive. Row 13 is Go's dotdot after symlink: under BI the .. applies after the link is resolved, so the result is a/f, as on Unix. Rows 3, 4, 6, 20, 21 and 22 must also leave MemVfs's call counter at zero where the refusal is lexical (VFM2).

The four remaining policies set crossMounts = true; rows 17 and 18 then swap, and every other row's outcome is unchanged.

Windows rows. On the Windows leg rows 7–14 run a second time with directory junctions in place of symlinks, because a junction needs no privilege to create. Directory symlinks need developer mode or a privilege; a leg without it records those rows as not run, not as passed. Three further Windows-only rows use openDir with CON, a:b and x. and expect invalidName (VFO1).

Oracle 2: scripted races ​

Each scenario runs on MemVfs with the interleaving hook (VFM3) firing at a named call index. Each names the attacker's action, the enforcement boundary, and the forbidden effect it must not produce.

#Setup and operationHook action (before call k)Required outcome
R1a/b/c/, secret beside the root; BI, walk("a/b/c/../../x")after a/b/c is opened, move a/b to outside/bthe .. steps return to held handles; the result is a/x or notFound, never outside/x (Go's TestRootRaceRenameDir)
R2a/b/; N, walk("a/b")after a is opened, replace a/b with s -> /symlinkRefused; no call touches a node outside the root
R3as R2, Bas R2escapesRoot
R4t/d/f; removeTree("t")after t is listed, replace t/d with s -> outside/the link is removed; outside/secret survives
R5t/a, t/b; removeTree("t")after t is listed, remove t/bsuccess (VFD4)
R6t/d/; removeTree("t")after t is listed, replace t/d with a filethe file is removed as an entry; success
R7root r/a/; walk("a") on a Dir for rbefore the call, move r to elsewhere/rok: the held root handle still names the moved directory
R8a/b/; N, walk("a/b")after a is opened, mount a device on a/bcrossesMount
R9f; writeFileAtomic("f", new)at every call index in turn, read feach read sees the complete old or the complete new content

The suite runs each scenario once for every call the unraced operation makes, firing the hook before that call, which covers every single-point interleaving rather than one chosen index.

The hook proves the resolver's logic under each interleaving. It does not prove that a real kernel's interleavings are covered; that evidence comes from oracle 6, which runs R1 and R4 natively with a concurrent renaming thread for a fixed iteration count and a recorded seed.

Oracle 3: kernel versus walk ​

On each leg with an accelerator, BlockingVfs exposes a test-only switch that forces the component walk. Oracle 1's rows, and the native R1 and R4, run twice: once with the accelerator and once forced onto the walk. The two runs must return the same object, compared by (device, inode) or the Windows file id at test time, or failures of the same kind (VFR1, VFE3). Comparing identities is legitimate here: the test compares two results it holds, and remembers nothing across time.

LegAcceleratorPolicies it covers
Linuxopenat2all eight
macOSO_NOFOLLOW_ANYN with crossMounts
WindowsOBJ_DONT_REPARSEN, both mount settings

The same switch drives the one-way downgrade test for VFR5 and VFN3: force the probe to report absence mid-run, then assert that a requireKernel root fails with unsupported and an ordinary root reports componentWalk.

The no-downgrade and retry checks need a backend double, not a real kernel: a resolveWhole that refuses or returns EAGAIN, and component primitives that record calls. They live with oracle 3 because they test the same seam.

Oracle 4: three-backend differential ​

One scenario list, written as data (tree literal, operation, expected result), runs on MemVfs, BlockingVfs and RingVfs. After each scenario the resulting tree is read back and compared with MemVfs's. Native trees are built by a small fixture writer in the test code, not by any library that is itself built on BlockingVfs.

Excluded from the list, because MemVfs does not model them (VFM1): names differing only in case, hard links, reparse tags, and permissions other than the recorded sharing. Each exclusion is covered by a native-only row in oracle 1 or 6. For sharing, the scenario compares MemVfs's recorded sharing (VFM5) with the mode bits or access control the native backend produced.

Oracle 5: compile-fail rights ​

A table of static assert(!__traits(compiles, …)) cases, one per operation and missing right from the operation set, plus: widening through attenuate; a borrow escaping its owner under -preview=dip1000; a handle of one backend passed to another; copying an owning handle; Shared() or PosixMode on a handle without createShared; PosixMode combined with executable; and PosixMode on Windows. A matching positive static assert for each proves the case fails for the intended reason and not a typo.

Oracle 6: native legs ​

LegAdditionally covers
Linux (x86_64, aarch64)a search-only directory (mode 0111) walked through by both resolvers (VFN14); openat2 flags, RESOLVE_NO_XDEV against a bind mount where the runner permits one, R1/R4 with a real renaming thread, created mode bits under a fixed umask
macOSa search-only directory walked through by both resolvers; the O_NOFOLLOW_ANY probe and its reported resolution; crossesMount by device comparison, labelled racy; created mode bits under a fixed umask
Windowsjunctions, the forced OBJ_DONT_REPARSE downgrade, removal of a git repository's read-only objects (VFN13), non-inheritable handles, the access control list of owner-only and shared entries

A leg that cannot create a bind mount records the row as not run.

Allocation ​

Every operation and algorithm runs under the libc allocation wrapper and the GC counter that sparkles:fuzzy uses, on each backend. The expected count is zero for every operation, including MemVfs with a pre-sized arena.

bash
dub test :base -- -i "vfs.check.allocation"                    # GC counter
dub test :base -c allocation-audit -- -i "vfs.allocation"     # Linux: libc wrapped too

The audit configuration first calls malloc once and requires the wrapper to count it, so a build without the --wrap flags fails instead of passing.

Requirement checks ​

One row per requirement: the observation that would falsify it, and where that observation is made.

Handles and rights (VFH) ​

RequirementCheckOracle
VFH1the backend counts open handles; every test ends with none; an empty .init handle fails every operation4
VFH2a @safe function that returns a DirRef to a local Dir does not compile5
VFH3passing a DirRef!(MemVfs, R) as renameAt's destination on a Dir!(BlockingVfs, R) does not compile5
VFH4the presets' members are pinned by a static assertunit
VFH5attenuate!(Rights.all) on a readOnly handle does not compile5
VFH6one compile-fail case per operation and missing right5
VFH7no call to a backend's native open outside backend modules; every openRoot call site names ambientAuthority()repository audit
VFH8a child process spawned while a Dir is held has no descriptor referring to it6

Operations (VFO) ​

RequirementCheckOracle
VFO1table-driven cases per platform, with the operation counter unchanged1
VFO2each operation applied to a symlink whose target is a sentinel outside the root leaves the sentinel unopened and unchanged1
VFO3each operation has a success and a failure case on every backend4
VFO4one case per mode combination4
VFO5the compile-fail rows for sharing; created bits or access control per sharing; a create without the argument on an attenuated handle records OwnerOnly on MemVfs4, 5, 6
VFO6a stat without the time mask records no time lookup on MemVfs; on POSIX the permission bits of a created file read back as created4
VFO7listing a directory twice through the same Dir, concurrently, yields the full set both times4, 6
VFO8two roots over one directory; a rename between them fails and neither tree changes4
VFO9R9; a reader loop concurrent with 1000 writes never sees a mixed or empty file on the Linux and Windows legs; the replacement has the requested sharing2, 6
VFO10rows 25 and 26, with the operation counter unchanged after row 251

Paths and resolution policy (VFP) ​

RequirementCheckOracle
VFP1a default-constructed policy has the stated valuesunit
VFP2a handle obtained by walk reports the root's policy; no function other than openRoot accepts a policyunit, 5
VFP3rows 2 and 3, plus table-driven cases on MemVfs with the operation counter unchanged1
VFP4rows 7–14 and 19 under N/NI, with a sentinel beyond the link that is never opened1
VFP5rows 7–14 and 19 under B/BI, on every backend1
VFP6rows 4–6; MemVfs's operation counter is unchanged after the call1
VFP7R1: a directory moved out of the root after being entered, then .., never reaches an object outside it2
VFP8rows 17–18 and R8; removeTree over a mounted subtree on MemVfs leaves it untouched1, 2

Resolution (VFR) ​

RequirementCheckOracle
VFR1every row runs on both resolvers and returns the same object or the same kind3
VFR2each native leg's reported resolution and mountCheck, for each of the eight policies, match VFN53, 6
VFR3the macOS leg with the default policy fails with unsupported6
VFR4a backend double whose resolveWhole refuses: afterwards, no component primitive was called3
VFR5the forced-absence switch on each native leg3
VFR6a backend double that returns EAGAIN forever yields raceRetryExhausted after exactly 129 calls3

Deletion (VFD) ​

RequirementCheckOracle
VFD1a tree one level deeper than the removal limit fails with depthExceeded; a sentinel beside it survives4
VFD2row 24 and R4: a link to a sentinel directory outside the tree; the sentinel's contents survive1, 2
VFD3a directory of 5000 entries is emptied in one call; R4 plants entries after a directory was emptied4, 6
VFD4R5: entries removed from under the removal; the call succeeds2
VFD5a directory made unremovable mid-tree; the error reports unlinkAt or rmdirAt, and a sentinel survives6

Errors (VFE) ​

RequirementCheckOracle
VFE1sparkles.event_horizon.errors declares no struct, and event-horizon's suite builds on the base typesrepository audit
VFE2the enum's filesystem members are pinned by a static assertunit
VFE3oracle 3 compares kinds and requires equality3
VFE4a failure from each operation reports its own OpKind4

Backends (VFB) ​

RequirementCheckOracle
VFB1all three backends satisfy isVfs, and a type missing one primitive does not5
VFB2one algorithm instantiation per backend4
VFB3a backend double counts one wholePathFor per openRoot; a kernel root's walk calls only resolveWhole3
VFB4oracles 1, 3, 4 and 6 on BlockingVfs1, 3, 4, 6
VFB5oracle 4 on each leg; a live environment on the select and IOCP backends contains fs4, 6
VFB6dub describe :event-horizon-sys lists only sparkles:base, expected, sparkles:reflection and sparkles:metadatarepository audit

Native backends (VFN) ​

RequirementCheckOracle
VFN1oracle 3 compares kinds across resolvers3
VFN2one fixture per table row asserts the kind6
VFN3the forced-absence switch, as for VFR53
VFN4the EAGAIN double, as for VFR63
VFN5each leg's reported resolution per policy matches the table; oracle 3 passes on Linux3, 6
VFN6the bind-mount row on Linux where permitted; macOS reports racy6
VFN7the Windows leg with real junctions and directory symlinks, the downgrade forced6
VFN8the NI and BI rows with .. on the Windows leg, junctions in place of links1, 6
VFN9the VFO2 check on every native leg1, 6
VFN10the VFO7 check on every native leg6
VFN11on Windows, a read-only File's queried access mask excludes FILE_WRITE_DATA6
VFN12mode bits under a fixed umask on POSIX; the queried access control list on Windows6
VFN13the Windows leg removes a tree containing a git repository's read-only object files6
VFN14a walk through a directory with mode 0111 succeeds on both resolvers on the Linux and macOS legs3, 6

The in-memory backend (VFM) ​

RequirementCheckOracle
VFM1oracle 1 runs on MemVfs with the native legs' expected outcomes, except the rows marked native-only1
VFM2a successful single-name open increments exactly one counter by oneunit
VFM3each oracle 2 scenario fires the hook at a chosen call index and asserts the outcome2
VFM4an arena sized for N nodes accepts N and refuses the next; no GC allocation is recordedallocation
VFM5oracle 4 compares the recorded sharing with the native result4

Worked traces ​

Three traces for adversarial review. Each is the sequence of backend calls a conforming implementation makes.

Success, Linux, default policy. root.walk("a/b"): lexical checks pass; the root's resolution is kernelWholePath; one call, openat2(root, "a/b", O_DIRECTORY | O_CLOEXEC, RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_XDEV); the result is a Dir with the root's rights and policy.

Failure, a symlink swapped in, macOS, default policy. The resolution is componentWalk, because the default crossMounts = false is not covered by O_NOFOLLOW_ANY (VFN5). openat(root, "a", O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) succeeds; the attacker replaces a/b with a symlink; openat(a, "b", …) fails with ELOOP or ENOTDIR; fstatat(a, "b", AT_SYMLINK_NOFOLLOW) reports a link; the kind is symlinkRefused (VFN2); the handle for a is closed; nothing beyond the link was opened.

Boundary, .. at the root, in-scope policy. root.walk("a/../.."): the walk enters a and pushes the root's handle; the first .. pops back to the root; the second .. finds the stack empty and fails with escapesRoot without a backend call (VFP7).

Evidence ledger ​

RequirementsStateEvidenceGap
VFE1, VFE2, VFE4; VFP1–VFP8; VFR2, VFR4; VFO1–VFO10; VFH1–VFH6; VFD1–VFD5; VFB1–VFB3; VFM1–VFM5verified on MemVfsoracles 1, 2, 5, the requirement checks and the allocation audit: dub test :base, 50 tests, on Linux x86_64 (LDC 1.42.0, DMD 2.112.1) and macOS 27 arm64 (LDC 1.42.0); the same tests cross-built for x86_64-pc-windows-msvc and run under Wine 11Windows itself runs only in CI; Wine is not Windows; on MemVfs, VFP8 exercises only the device comparison, and VFR4 a backend double
VFB4; VFN1–VFN3, VFN5, VFN6, VFN9, VFN12 (mode bits), VFN14; VFR1, VFR2, VFR5; VFP8 on a real mount; VFH8verified on BlockingVfs (POSIX)oracles 1, 3 and 4 and the native requirement checks: dub test :event-horizon-sys, 10 tests, on Linux x86_64 6.18 (openat2 resolver, and the component walk forced) and macOS 27 arm64 (O_NOFOLLOW_ANY); removing RESOLVE_NO_SYMLINKS turns 52 oracle-3 cases red on the kernel resolver and none on the walkFreeBSD and other POSIX systems are not run; VFP8 and VFN6 use /proc or /dev, not the bind-mount row
VFB6verifieddub describe :event-horizon-sys: its only dependency is sparkles:base, whose closure is expected, sparkles:reflection and the dependency-free sparkles:metadata; silly is listed only through expected's own unittest configuration, and no package depends on it—
VFN2, VFN5, VFN6, VFN8; oracles 1, 3 and 4 on the NT backendpartly verifiedthe Windows BlockingVfs tests cross-built for x86_64-pc-windows-msvc and run under Wine 11Wine has no junctions or resolvable symbolic links, so the link rows of oracle 1 and VFN7 are skipped; Wine removes a non-empty directory under POSIX-semantics delete; the windows-latest leg decides all of these
VFN7, VFN12, VFN13pendingtests exist and skip under Wine (no links, no protected DACL, read-only files refuse write access)runs only on the windows-latest leg
VFN4, VFN10unverified—VFN4 has no EAGAIN double, so the retry loop is read, not tested; VFN10 has no native VFO7 check yet
VFN11not implemented—the NT backend opens every directory with list, traverse and read-attributes access whatever its rights, and files with the access the open mode needs; the rights → access-mask mapping is M3 work
VFE1 in full; VFB5 for resultsverified on RingVfs (Linux, macOS)event-horizon's IoError is base's; oracle 1 on the pool and ring paths with both resolvers, oracle 4 across MemVfs, BlockingVfs and RingVfs, and a counter showing which path each call took: dub test :event-horizon, 246 tests, 7 skipped, on Linux x86_64 6.18; 155, 6 skipped, on macOS 27 arm64, where every call takes the poolthe event-horizon suite does not compile for Windows (its tests use POSIX pipes)
VFB5 on Windowspartly verified (Wine)the oracle-4 scenario cross-built for x86_64-pc-windows-msvc and run under Wine 11: RingVfs matches the NT BlockingVfs it wraps, inline and on an IOCP scheduler fiber, and the Effect!T forms run thereWindows has no capability row (event-horizon's live is POSIX-only), so env.fs does not exist there; the program is a local check, not in the repository; Wine removes a non-empty directory, so the NT backend differs from MemVfs on that one call
VFB5 for where calls runverified (Linux)openat2, statx, mkdirAt, unlinkAt, rmdirAt, renameAt and symlinkAt go to the ring where the kernel supports the opcode, shown by a per-operation counter; the kernel-resolver attack rows, the three-backend differential and a real-mount test run on that path; statx and fstatat give equal Stats, device included, for a file, a directory, a link and /proc/selflisting, readlinkAt and the single-name opens run on the pool, as VFB5 allows for the first two; the kqueue and IOCP backends run every call on the pool or inline
every other requirementunverified—no evidence recorded

The independent-review item of the specification's acceptance gate is unmet: the specification was written by the same agent that ran the design review, and no second reviewer has inspected it.