Skip to content

Comparison and Recommendations ​

The capstone: what the twenty-six subjects agree on, where they split, and what that implies for sparkles.base.dir_handle — the directory-handle module that sparkles:test-utils' TmpFS and the fileset walker (FSD5–FSD7) will stand on.

Last reviewed: September 22, 2026

The consensus, in one paragraph ​

Every surviving design descends from one 1996 sentence — "path names are indirect pointers … file descriptors are direct pointers" (Bishop & Dilger) — and one 2009 verdict: any defense that re-resolves a name and hopes to be faster than the attacker is dead, because the attacker can make any lookup, even of a non-existent name, take milliseconds (Cai et al., after Borisov et al. broke the k-race). What is left is walking once, from a handle: in the kernel where a scoped lookup exists (openat2, O_RESOLVE_BENEATH), and otherwise one component at a time with O_NOFOLLOW on every step (Chari 2010 is the template; Go, Rust, cap-std, libpathrs and gnulib are the implementations). Deletion follows the same rule: open each directory relative to its parent, list it from the handle, unlink relative to it, never descend a link (Rust's CVE-2022-21658 fix, CPython, gnulib fts).

Master table ​

SubjectKindPrimitive..SymlinksMountsFallbackDeletion
Linux openat2kernelwhole-path resolve mask, 5.6+beneath allowed; EAGAIN on raceper flagRESOLVE_NO_XDEVENOSYS/EPERM → caller's walk—
procfs magic linkskernelRESOLVE_NO_MAGICLINKS; fsopen+open_tree private procfs—magic links = jumpsSTATX_MNT_IDf_type list, ":[]" heuristic—
FreeBSD / OpenBSDkernelO_RESOLVE_BENEATH (13+), Capsicum, O_EMPTY_PATH (14)temporal escape refusedabsolute refused—probe by behaviour (13 ≠ 14)—
DarwinkernelO_NOFOLLOW_ANY, AT_SYMLINK_NOFOLLOW_ANY, *_RESOLVE_BENEATHlexicalnone, whole path—no surveyed consumer uses them—
Windows NTkernelNtCreateFile + RootDirectory, OBJ_DONT_REPARSElexical only (parser, not lookup)reparse zoo refused whole-pathvolume id in FILE_ID_INFOprobe OBJ_DONT_REPARSE, downgrade oncehandle-based POSIX
libpathrslibraryopenat2 → O_PATH walkre-check via /proc/thread-self/fdbeneath (kernel) / re-fed (walk)not NO_XDEV (TODO)one-way failure cache; never downgradefd-relative, unbounded
filepath-securejoinlibraryopenat2 → O_PATH walk (Go)as libpathrsas libpathrsnonenever downgrade per-lookupnone
Go os.Rootruntimeper-component O_NOFOLLOW walk (no openat2)drop + restart from root; 255/8 boundspliced, max 8out of scope, documentedjs / Plan 9 name-based tierremoveAllFrom
Rust stdruntimeremove_dir_all walk; fs::Dir (unstable)n/aELOOP or ENOTDIR → unlinknonecompile-time CVE algorithm on 7 targetsfd-relative, 50 retries (Win)
rustixbindingevery *at, openat2 raw 437, Dir::read_from re-openpolicy-freepolicy-freepolicy-freenonenone
cap-stdlibraryopenat2 BENEATH|NO_MAGICLINKS → manual stack walkpop ancestor stackspliced, max 40/63; absolute refusednone4 EAGAIN retries then silent walk2020 std algo
openat / openat-extbindingthin *at wrapperkernelO_NOFOLLOW on some callsnonenoned_type-trusting
gnulib ftswalkerFTS_CWDFD: openat per dir, fd ring for ascentpop ring; else re-open + dev/inoO_NOFOLLOW iff FTS_PHYSICALFTS_XDEV opt-in/proc/self/fd/N/name → fchdir → pathsunlinkat (coreutils rm)
CPython shutilwalkerlstat → open(dir_fd) → fstat + samestatn/aidentity compare, no O_NOFOLLOWnone_rmtree_unsafe (always on Windows)explicit stack (3.14)

The nine papers are not rows: they are the reasons the rows look like this. Their one-line contributions are in the umbrella's milestones.

Axis 1: where the walk happens ​

WhereWhoWhat it buysWhat it costs
Kernel, whole pathopenat2, O_RESOLVE_BENEATH, OBJ_DONT_REPARSEone syscall; kernel locks; .. checked during the walkLinux ≥ 5.6 / FreeBSD ≥ 13 / Windows ≥ Vista-ish only; still EAGAIN under rename
User space, per componentGo, Chari, cap-std manual, libpathrs opath, gnulibworks everywhere openat exists (macOS 10.10+, every BSD, Linux)O(depth) syscalls; .. must be handled by the caller; symlinks must be re-fed
User space, by nameGo on js/Plan 9, CPython on Windows, classic SecureJoinnothing to installthe TOCTTOU every paper describes

The field's direction is unambiguous, and so is its lag: the kernel answer is six years old, yet Go ships without it and libpathrs keeps a full fallback resolver. A portable module must be designed around the component walk and treat the kernel path as an accelerator, not the reverse.

Axis 2: what .. means ​

This is the axis on which the kernels disagree with each other, and the one a portable API has to settle by fiat:

  • Linux RESOLVE_BENEATH allows a .. that stays beneath and fails closed with EAGAIN when it cannot prove it did (flag matrix).
  • FreeBSD O_RESOLVE_BENEATH refuses "even the temporal escape" — and FreeBSD 13 and 14 behave differently at the root, which is why cap-std probes behaviour rather than presence.
  • Windows never resolves .. in a lookup at all; it is a parser feature, which is why Go cleans lexically first with a \\?\?\ guard.
  • Every user-space walker either rejects .. before the first syscall (Go's errPathEscapes for more .. than components, this catalog's walk example) or pops an ancestor-handle stack (cap-std, gnulib's fd ring) — never openat(dir, ".."), "because dir may have moved since we opened it".

For a module whose consumers are a test fixture and a build-tree walker, the only behaviour that is identical on every platform is the strictest one: reject .. lexically. Anything looser means a fixture test that passes on Linux and fails on macOS.

GuaranteeLinuxFreeBSDDarwinWindowsComponent walk
No symlink at allRESOLVE_NO_SYMLINKSwalkO_NOFOLLOW_ANYOBJ_DONT_REPARSEO_NOFOLLOW per step
Beneath, links okRESOLVE_BENEATHO_RESOLVE_BENEATHwalk + splicewalk + FILE_OPEN_REPARSE_POINT + readsplice link text

"Beneath" is the richer semantics and what cap-std and libpathrs chose. It costs a symlink re-feeding loop with a depth bound (8 in Go, 40/63 in cap-std, MAXSYMLINKS in the kernel), and on Windows it costs reading the reparse buffer by hand. "No symlinks" is one flag on three kernels and one O_NOFOLLOW on the fourth, and it is what Rust's remove_dir_all, gnulib rm and every deletion path already do. Recommendation: no-symlinks as the floor; beneath as an opt-in the module can grow later, since the fileset spec's node model reads links as values and never needs to follow one.

Axis 4: the fallback ladder and how it is detected ​

LibraryDetects withCachesOn EAGAIN exhaustionDowngrades per-lookup?
libpathrsfirst ENOSYS; EPERM countedfailure onlysurfaces EAGAIN, 128×never — "downgrade attack"
securejoinsamefailure onlysurfaces EAGAIN/EXDEVnever (lookup_linux.go)
cap-stdENOSYS static; EPERM per call; skip Androidsuccess+failurereturns ENOSYS, 4×yes, silently — a rename storm changes resolver
Rust stdcompile-time target list——n/a
Rust std WinOBJ_DONT_REPARSE once, INVALID_PARAMETERatomic, one-way—downgrade-only, never re-probed
gnulibopenat_needs_fchdir() at fts_open——silent to FTS_NOCHDIR
CPythonos.supports_dir_fd at import——publishes avoids_symlink_attacks

Two rules fall out. Cache failure, never success — "a process can always add seccomp-bpf filters to itself" (libpathrs). Do not fall back per lookup — if openat2 works and refuses this path, the walk must not be given a second chance (securejoin). And one honesty rule from CPython: publish which resolver is live (avoids_symlink_attacks), the same posture as the spec's FSD6.

Axis 5: identity ​

IdentityWho relies on itWhy it is not enough
(st_dev, st_ino)gnulib fts_safe_changedir, CPython samestat, Chariinode numbers are reused on ext4/XFS/ReiserFS/NILFS2 (Raducu Table 4); hard-link swaps defeat symlink-only checks (Cai)
FILE_ID_INFOGo, Rust on Windows128-bit id + volume serial — durable, but not a lock
The open handleevery fd-relative designthe only identity that cannot be swapped: what you hold is what you opened

Recommendation: never remember a tuple to compare later; hold the handle.

Axis 6: deletion ​

All five deletion implementations converged on the same shape and disagree only at the edges:

  • Classify without following: openat(O_DIRECTORY|O_NOFOLLOW) and treat ENOTDIR or ELOOP as "unlink it" (Rust; this catalog's example shows Linux returns ENOTDIR for a symlinked intermediate). CPython instead compares lstat to fstat (cpython) — the only design with no O_NOFOLLOW at all.
  • List from the handle — and do not dup it into fdopendir: the shared file description is undefined behaviour territory, so rustix re-opens with openat(fd, ".").
  • Re-read until empty (libpathrs) or close-and-reopen per 1024-name batch (Go), because deleting while iterating skips entries.
  • Windows: FILE_DISPOSITION_INFORMATION_EX with POSIX_SEMANTICS | IGNORE_READONLY_ATTRIBUTE, fall back to the classic disposition on INVALID_PARAMETER, treat DELETE_PENDING as gone, retry SHARING_VIOLATION (Rust); a directory symlink opened by handle iterates as empty.
  • Bounds: nobody but gnulib (fd ring) and CPython (explicit stack) bounds depth or descriptors; libpathrs and cap-std recurse unbounded.

What nobody does ​

Findings by absence, which the research guideline asks for:

  • Nobody uses Darwin's O_NOFOLLOW_ANY. Go, Rust and cap-std all walk.
  • Nobody checks mounts in a general resolver. libpathrs has it as a TODO, cap-std and Go declare it out of scope; only libpathrs's procfs handle and gnulib's opt-in FTS_XDEV care.
  • Nobody re-verifies after openat2; the kernel's answer is trusted.
  • Nobody ships without a fallback except rustix (which ships no policy).
  • The academic survey does not know this lineage exists: Raducu 2022 contains none of openat2, O_RESOLVE_BENEATH, libpathrs or os.Root.

Delta: dir_handle against the field ​

CapabilityFieldSparkles todayProposed
Handle-relative openuniversalnone — TmpFS builds paths with buildPathDirHandle over int/HANDLE, move-only, closes on destruct
Lexical .. / absolute rejectionGo (Unix), Go (Windows, cleaned), examplesenforceBeneath string check (517803e5e)keep, as the portable layer, before any syscall
Whole-path no-symlink lookupRESOLVE_NO_SYMLINKS, OBJ_DONT_REPARSEnoneLinux openat2(BENEATH|NO_SYMLINKS|NO_MAGICLINKS); Windows OBJ_DONT_REPARSE
Component-walk fallbackGo, cap-std, libpathrs, gnulibnoneopenat(O_NOFOLLOW|O_DIRECTORY|O_CLOEXEC) per step; the one walker that runs on Darwin
Detection policyfailure-only cache; never downgrade per lookup—probe once, cache ENOSYS; EPERM per call; a refused openat2 lookup is a refusal
Capability reportingavoids_symlink_attacksFSD6 (spec only)DirHandle.resolution() → kernelWholePath / componentWalk
Fd-relative deletionRust, CPython, gnulib, libpathrs, Gostd.file.rmdirRecurse by pathremoveTree: re-open . for listing, ENOTDIR|ELOOP → unlink, bounded depth; Windows POSIX delete with fallback
Mount boundarynobody (general); FSD7 wants it—RESOLVE_NO_XDEV on Linux; st_dev/volume-serial compare elsewhere, documented as racy
Retry budget on EAGAIN128 (libpathrs) vs 4 (cap-std)—explicit constant, surfaced as an error kind when exhausted — never a fallback
Test oracleTestRootRaceRenameDir, avoids_symlink_attacks testsnoneplant links/junctions and a rename race; assert which resolver ran

Open questions the research does not settle ​

  1. Beneath or no-symlinks as the default for the fileset walker's FsoRef once it needs to read a link's target — RESOLVE_NO_SYMLINKS refuses to open the link itself; O_PATH|O_NOFOLLOW plus readlinkat is the libpathrs answer, and it needs a second call.
  2. Whether to expose an O_PATH-style "located but not opened" handle at all, given that re-opening it on Linux goes through /proc/self/fd and FreeBSD 14 has O_EMPTY_PATH while Linux's RESOLVE_EMPTY_PATH never merged.
  3. How much of Windows to promise: OBJ_DONT_REPARSE needs a version probe and a downgrade path; the per-component reparse-tag check is the honest floor, and CPython's choice — no fd-relative path on Windows at all — is the floor below that.