fs::Root, and Anywhere Is the One Visible Way Around the CheckStatus: Accepted
Date: September 16, 2026
Related: ADR-010 D2, D3 and D8 (provenance; trusted: is
about a file’s content; the lattice and what consumes it), ADR-289
D7 (http::File takes the same root), Part I 5.1 (a subject is required, an
option has a default), Part I 4.4 (an enum and how a variant is written),
Part III 17.1 (std::fs, and nikaia --trust under std::collections)
The hole is path traversal: a name somebody else chose, joined under a
directory, reaching the filesystem and leaving the directory through .. or a
symlink. A ../../etc/shadow answered with a 200 is not a failure a status
code fixes afterwards.
A language can close it in two places. It can know where each value came
from — a taint per value, followed through let, concatenation, f"…" and
every call, and refused at the filesystem — or it can put the check in the
call that reaches the filesystem, so that the name and the directory it may
not leave arrive together and the library compares them.
The first is Perl’s taint mode moved to compile time, and Perl is the precedent
to read carefully. The mark is invisible in the code and lives in the
interpreter’s head, which is why most programmers never understood it; and the
mark comes off through any regex capture, so the gesture of checking
counts as the check, and ($clean) = $input =~ /(.*)/ untaints everything
while checking nothing. Built here it would also be a second analysis:
contracts::trust is a whole-program join, and a per-value answer is a
dataflow pass over locals with a new ledger column to carry it across a call.
The second costs one argument per call and needs nothing the compiler does not
have. It is what this record takes, for three reasons that are the same three
objections to the first: a programmer sees a missing argument and not a taint;
the check is std’s and not the program’s, so checked and correctly checked
are the same thing; and the way around it is one word, written where it
happens and listed by a report.
std function that takes a path takes its root, right after the path, and there is no default.let data = fs::map(name, fs::Root::Dir(store)) // name may not leave store
let text = fs::read_to_string("eins.txt", fs::Root::Anywhere)
fs::write(out, fs::Root::Dir(site), markup; append: true)
fs::read, fs::read_to_string, fs::write, fs::map, fs::open,
fs::exists, fs::metadata, fs::read_dir, fs::create_dir, fs::remove,
and http::File the day it exists. fs::rename and fs::copy take two paths
and one root after both, and both names must lie under it.
The root is a subject and not an option, and that is Part I 5.1’s rule
doing the work: an option has a default, and a default here is the hole — a
call that could leave the root out is a call nobody can be sure remembered it.
A call that leaves it out is NK1101, the same refusal as any missing
argument, and the message names the two forms of D2.
There is no exception for a literal. fs::read_to_string("eins.txt", …)
names its root like every other call, because a relative name is resolved
against the working directory, and whoever controls that directory — or a
symlink in it — has changed what the name means without changing a line of the
program. So the rule is one sentence, and a reader never has to know which
arguments are exempt.
fs::Root, an enum with two variants, written the way every variant is written.enum Root {
Dir(Path), // the name is resolved under this directory and may not leave it
Anywhere, // no check — the program answers for the name
}
fs::Root::Dir(store) and fs::Root::Anywhere, with the type in front, as
Part I 4.4 writes every variant. There is no shorthand: not a bare String
where a Root is wanted, which would be a conversion rule this language does
not have, and not a second spelling beside the variant, which would be two
ways to write one thing. A quieter spelling is a convenience a later record may
add; it is not a door this one closes (§4).
Dir is checked at the call, and a name that leaves it is an error, never a rewritten name.Changed by ADR-280 D7: the refusal is io::IoError::Outside, a case of std’s one I/O error type, not an error type fs::Outside of its own.
The root and the name joined under it are both resolved — symlinks
followed, .. applied — and compared component by component, never as a
string prefix, because /data is a prefix of /data2. A name that resolves
outside the root makes the call throw io::IoError::Outside, one case beside not
found among the errors the entry already names, and a handler answers it with
404.
It is a refusal and not a sanitiser. A hostile name is not trimmed into a
polite one, because a program that quietly serves a different file than the
one asked for is a worse bug than one that serves nothing. An absolute name
under a Dir gets the same treatment: resolved, compared, and fine where it
lies inside.
Anywhere is the opt-out, and it is visible three times.At the line, where the word stands in the call. In the ledger, where every
site that writes it is recorded with its location, as trusted: true already
is (ADR-010 D3). And in nikaia --trust, which lists every
Anywhere and every Dir whose root is the literal "/", since that is the
same thing in another spelling. The security review of a program’s file access
is that list, before a release.
Anywhere does not say checked. It says I answer for this. And there is
no third gesture: no function the program writes clears anything, only std’s
own comparison does, which is the door Perl left open and this record keeps
shut.
The trust lattice of ADR-010 has one consumer, the hasher (D5 of
that record), and this is not a second one. There is no fs::within, and there
is no compile-time refusal of an Untrusted value at a path parameter — the
check is in the call, where the name and the root are both in hand.
trusted: false on fs::map stays what it is: a statement about the file’s
content (ADR-010 D3), which is a different question from
where the file is, and this record does not touch it.
fs::Root::Anywhere — an operator’s own argument may
point anywhere. In a server a Dir is written once per handler.fn load(name: Path) that calls
fs::map(name, fs::Root::Anywhere) puts the word in the helper, and the
report lists the helper. A helper that wants to pass the decision up takes an
fs::Root itself and hands it on.enum, a required argument and a
thrown error are three things the language already has; the diagnostic for
the missing argument is NK1101, which already exists.Dir, which is the check a server performs anyway, and nothing
under Anywhere.Dir(root) — that can be added later without breaking
a program, since every existing call keeps working.Dir reads a Windows root; the comparison is by component and the
platform’s resolution is what is compared.