ADR-108: A Path Names Its Root at the Call — fs::Root, and Anywhere Is the One Visible Way Around the Check

Status: 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)

1. Context

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.

2. Decisions

D1 — Every 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.

D2 — The root is 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).

D3 — A 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.

D4 — 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.

D5 — No provenance travels a path, and no analysis follows one.

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.

3. Consequences

4. What this does not decide

This site is open source. Improve this page.