Skip to content

Refuse a symbolic link under experiments/ (#61) - #132

Merged
iderex merged 1 commit into
mainfrom
scaffolding/a-link-under-experiments-is-refused
Aug 13, 2026
Merged

Refuse a symbolic link under experiments/ (#61)#132
iderex merged 1 commit into
mainfrom
scaffolding/a-link-under-experiments-is-refused

Conversation

@iderex

@iderex iderex commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Closes #61

What this changes

A symbolic link anywhere under experiments/ is refused where the walk meets
it, and is never opened, resolved or descended into. That is the half of #61
that PR #106 left, and the loop fixture that depended on it.

The other change is what makes the first one provable. The walk reads through a
filesystem it is given rather than through the one the process runs on. The
public entry is unchanged and still takes a path; Walk opens the tree and
hands it to the walk, and the path it was given is carried alongside for one
purpose, which is building the path a refusal names. Subjects are byte for byte
what they were.

A case may now declare that one of its entries is a link, in a links file
beside the expectation. Two new cases use it:

  • a-symbolic-link-under-experiments, whose experiments/up is declared a link
    and whose target is ..
  • an-ordinary-file-where-a-link-would-be, its near neighbour

Their trees are identical byte for byte. The only difference between them is
the one line declaring the entry a link, so the refusal is shown to be about the
link and not about the name, the place or the bytes.

Why the fixture is declared rather than made

#61 offers two routes for the link fixture and the measurement on that issue
closes both.

A link built at run time needs SeCreateSymbolicLinkPrivilege on Windows, which
an ordinary account does not hold, and os.Symlink returns windows error 1314
there. Record 0007 keeps the default run unelevated, so that route either fails
on a machine for a reason that has nothing to do with the runner, or skips,
which #57 rules out for a platform.

A tracked link arrives on such a checkout as an ordinary file holding its target
as text. A case built that way asks the runner about a link on one platform and
about a text file on another while declaring a single answer for both, and
record 0012 runs the suite on windows/amd64.

A directory entry handed to the walk needs no privilege and is the same on every
platform. The bytes under tree/ are still exactly what the walk read; the only
thing the harness supplies is the type of one entry. That departure and its
reason are written at the harness, where the layout of a case is decided, rather
than only here.

What failure it prevents

An experiment reached only through a link stated its question to nobody. Every
rule about a record reads one the walk found, a link was walked past without
being counted or refused, and the run then printed a directory count and a
record count that were both silently short. Nothing went red and nothing said
that anything had been left out, which is the shape this board exists to make
impossible.

What was run

The gate, at this commit:

$ go build ./cmd/... ./internal/... && go vet ./cmd/... ./internal/... && gofmt -l cmd internal
(gofmt printed nothing)

$ go test -count=1 ./cmd/... ./internal/...
ok  	github.com/Flowfin/lab/cmd/contexts	0.901s
ok  	github.com/Flowfin/lab/cmd/lab	2.648s
ok  	github.com/Flowfin/lab/cmd/pullrequest	0.851s
ok  	github.com/Flowfin/lab/internal/check	2.662s
ok  	github.com/Flowfin/lab/internal/contexts	2.623s
ok  	github.com/Flowfin/lab/internal/hardware	0.957s
ok  	github.com/Flowfin/lab/internal/invariants	2.033s
ok  	github.com/Flowfin/lab/internal/prose	1.419s
ok  	github.com/Flowfin/lab/internal/pullrequest	1.138s

gofmt -l cmd internal printing nothing is its passing result. The runner still
refuses nothing on this tree, and the counts are the ones it printed before:

$ go run ./cmd/lab check .
examined .
1 experiment directory walked, 1 record read
18 decision records read
the time this run read is 2026-08-13T00:23:31Z
0 refused

The standing requirement refused this change before the case existed, which is
the harness doing its job:

--- FAIL: TestEveryRefusalHasAFixtureThatProvesItBites
    properties_test.go:141: experiments-holds-a-symbolic-link can be refused and no case declares exactly that refusal and no other

Each guard proved by breaking it

Three mutations, each the smallest one that could be made, run and reverted.

The refusal site deleted:

--- FAIL: TestCases/a-symbolic-link-under-experiments
    check_test.go:46: expected refusal not produced: experiments-holds-a-symbolic-link

!= 0 written as == 0, which is the one-character mistake somebody makes
here. It reddens the near neighbour, which is what that neighbour is for, and it
reddens twelve further cases that carry an ordinary file under experiments/:

--- FAIL: TestCases/an-ordinary-file-where-a-link-would-be
    check_test.go:46: refusal produced that no case expected: experiments-holds-a-symbolic-link

It does not redden the refusing case, because that tree also holds a record
file, and with the bit inverted the record file trips the property instead. The
first mutation is what covers that direction.

The harness no longer applying the declaration, which is the mutation that would
otherwise leave the case green against an ordinary file:

--- FAIL: TestCases/a-symbolic-link-under-experiments
    check_test.go:46: expected refusal not produced: experiments-holds-a-symbolic-link

TestADeclaredLinkIsReportedAsOne holds the same thing standing, and it also
refuses the day the entry becomes a link on disk, since a declaration over
something that is already a link proves nothing about the declaration.

The means

Go, in the package that already walks this tree, with a case in the harness that
already proves every other refusal here. Reading through io/fs adds no
language, no runtime and no dependency: it is in the standard library this
module already builds on, and it is the interface the walk was already half
written against, since fs.DirEntry and fs.SkipDir were in these signatures
before this change. Nothing outside this repository forces a different means.

What this does not do

It does not follow a link to find out where it goes, and it makes no claim about
where one goes. A link under experiments/ pointing at a file inside this
repository is refused exactly like one pointing outside it. Where a link lands is
a question about a filesystem that no reading here can be right about on every
platform the release targets.

It refuses no link outside experiments/. The rest of a checkout is somebody's
own arrangement of their own machine, and this board has nothing to say about it.

It is not a sandbox and adds none. The runner runs with the privileges of
whoever started it and reads what it is pointed at.

The loop half of #61 is met by the same case rather than by a second one, and
what that is worth is bounded. experiments/up points at .., so anything that
resolved it would re-enter experiments/ without end, and the suite completing
is the evidence that nothing does. What it does not prove is that the refusal is
what stops the loop: a directory walk does not follow links to begin with, so
that tree terminated before this change too.

The pair proves what the runner does when it meets an entry of that type. That
an operating system reports a link as an entry of that type is the standard
library's behaviour rather than this runner's, and no case here proves it.

Reading

Nothing in this change has been read by a second person. The evidence above
stands in place of that reading, and this sentence is here so that the absence
is not mistaken for a review that happened.

The tree under experiments/ is written by whoever proposes an experiment, so
it is the runner's input rather than its environment. A link in it was walked
past in silence: an experiment directory reached only through one stated its
question to nobody, because every rule about a record reads one the walk found
and the walk found none. It is now refused where the walk meets it, and never
opened, resolved or descended into.

The walk reads through a filesystem it is given rather than through the one the
process runs on, and that is the whole reason the refusal can be proved. A
fixture that asks the machine for a link cannot be had on every platform the
suite runs on: creating one on Windows needs SeCreateSymbolicLinkPrivilege,
which an ordinary account does not hold, and os.Symlink returns error 1314
there. A tracked link arrives on such a checkout as a text file, so a case built
that way asks about a link on one platform and about a file on another. Record
0007 keeps the default run unelevated and record 0012 runs the suite on
windows/amd64, so neither route was available. A directory entry supplied to the
walk needs no privilege and behaves the same everywhere.

The case and its near neighbour are identical byte for byte and differ only in
the line declaring the entry a link, so the refusal is shown to be about the
link rather than about the name, the place or the bytes. What that pair leaves
unproved is that an operating system reports a link as an entry of that type,
which is the standard library's behaviour and not this runner's, and the harness
says so where the departure is made.

Subjects are unchanged: every refusal still names a path written the way the
reader's machine writes one.

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
@iderex iderex added enhancement New feature or request security labels Aug 13, 2026
@iderex iderex self-assigned this Aug 13, 2026
@iderex iderex added this to the 2. Repository scaffolding milestone Aug 13, 2026
@iderex
iderex merged commit fa3ca26 into main Aug 13, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Treat the tree the runner walks as untrusted input

1 participant