LibFs.buildFileForContract's unlink loop deletes what a symlink at the
generated path points at, before it removes the link. Anything the project's
fs_permissions grant reaches can be destroyed by a routine generation run.
Found by CodeRabbit on PR #137
(#137), but the defect is on
main and predates that PR — #127 introduced it and it is already merged.
Where
src/lib/LibFs.sol:182-189, inside the five-arg buildFileForContract
overload — so both the four-arg wrapper and the tagged writes reach it.
Problem
The code states the behaviour in its own comment and then relies on it:
// `vm.removeFile` resolves the path before it acts, so on a live symlink
// it takes what the link points at and leaves the link, now dangling.
// Every pass removes something the next one no longer finds, so this
// ends with the path holding nothing.
while (isPresent(vm, path)) {
//forge-lint: disable-next-line(unsafe-cheatcode)
vm.removeFile(path);
}
For a live symlink at path:
- Pass 1 —
vm.removeFile resolves the link and deletes its target, which
is a different file at a different path. The link survives, now dangling.
- Pass 2 —
isPresent still reports true (it uses vm.readLink, which sees a
dangling link where vm.exists does not), so vm.removeFile runs again and
removes the link itself.
vm.writeFile then writes the generated source at path.
The loop's goal — leave path holding nothing, so the write cannot follow a
link — is right. Reaching it by deleting the target is not: the caller asked to
generate at path, and nothing about that asks for a second, unrelated file to
be removed. The deletion is silent; no revert, no message, and the run reports
success.
What it can reach
Bounded by fs_permissions in foundry.toml, which today is:
fs_permissions = [
{ access = "read", path = "foundry.toml" },
{ access = "read-write", path = "src/generated" },
{ access = "read-write", path = "meta" },
]
So a symlink at src/generated/<Name>.sol pointing into meta/ makes a routine
generation delete a meta/ file.
That matters more than it would have yesterday: meta/ now holds committed
input artifacts, and they ship in the published soldeer package (rulings on
#133, 2026-08-17 — meta is out of .gitignore and out of .soldeerignore).
describedByMetaHashConstantString reads meta/<name>.rain.meta. Deleting one
during a build destroys committed, published input.
A consumer's own fs_permissions may grant more than this repo's, and the same
library code runs there.
Not a hypothetical, and already observed
#127's own investigation measured the target being destroyed — on main before
its loop, a link src/generated/ProbeELive.sol -> ProbeELiveTarget.txt whose
target held SENTINEL came back with the generated source written into the
target and SENTINEL gone. With the loop it measured meta/ "left empty" and
recorded that as the fix working. Both readings describe the same deletion; only
the second file's fate differs.
So main has never left a live symlink's target alone: before #127 it wrote
through to it, after #127 it deletes it.
Proposed fix
Remove only the link, never what it resolves to.
vm.removeFile always resolves first, so there is no cheatcode path to this in
forge-std 1.16.2. This repo already sets ffi = true in foundry.toml, for
exactly this neighbourhood — its symlink tests build links with ln because no
cheatcode creates one. The same door opens unlink/rm on the link itself.
Whatever shape it takes, the behaviour to land is:
- A live symlink at
path is removed; its target is untouched, byte for byte.
- A dangling symlink at
path is removed (already true; keep it).
- A regular file at
path is removed (already true; keep it).
- A directory at
path still reverts rather than being cleared (already true).
- Termination is preserved — the loop must still not spin on a chain or a cycle.
Each of those wants a test, and the live-symlink one must assert the target's
contents survive, which is the assertion no current test makes:
testBuildFileForContractReplacesLiveSymlink asserts only that path ends as a
regular file, which is why the deletion passed review twice.
Also worth deciding while here
Whether a live symlink at a generated path should be replaced at all, or
refused. Refusing is the more conservative contract for a build-time library
that writes into a directory the consumer commits, and it makes the target
question moot. #127 chose replace; this issue does not reopen that, but the
choice is now visible in a way it was not.
Related
LibFs.buildFileForContract's unlink loop deletes what a symlink at thegenerated path points at, before it removes the link. Anything the project's
fs_permissionsgrant reaches can be destroyed by a routine generation run.Found by CodeRabbit on PR #137
(#137), but the defect is on
mainand predates that PR — #127 introduced it and it is already merged.Where
src/lib/LibFs.sol:182-189, inside the five-argbuildFileForContractoverload — so both the four-arg wrapper and the tagged writes reach it.
Problem
The code states the behaviour in its own comment and then relies on it:
For a live symlink at
path:vm.removeFileresolves the link and deletes its target, whichis a different file at a different path. The link survives, now dangling.
isPresentstill reports true (it usesvm.readLink, which sees adangling link where
vm.existsdoes not), sovm.removeFileruns again andremoves the link itself.
vm.writeFilethen writes the generated source atpath.The loop's goal — leave
pathholding nothing, so the write cannot follow alink — is right. Reaching it by deleting the target is not: the caller asked to
generate at
path, and nothing about that asks for a second, unrelated file tobe removed. The deletion is silent; no revert, no message, and the run reports
success.
What it can reach
Bounded by
fs_permissionsinfoundry.toml, which today is:So a symlink at
src/generated/<Name>.solpointing intometa/makes a routinegeneration delete a
meta/file.That matters more than it would have yesterday:
meta/now holds committedinput artifacts, and they ship in the published soldeer package (rulings on
#133, 2026-08-17 —
metais out of.gitignoreand out of.soldeerignore).describedByMetaHashConstantStringreadsmeta/<name>.rain.meta. Deleting oneduring a build destroys committed, published input.
A consumer's own
fs_permissionsmay grant more than this repo's, and the samelibrary code runs there.
Not a hypothetical, and already observed
#127's own investigation measured the target being destroyed — on
mainbeforeits loop, a link
src/generated/ProbeELive.sol -> ProbeELiveTarget.txtwhosetarget held
SENTINELcame back with the generated source written into thetarget and
SENTINELgone. With the loop it measuredmeta/"left empty" andrecorded that as the fix working. Both readings describe the same deletion; only
the second file's fate differs.
So
mainhas never left a live symlink's target alone: before #127 it wrotethrough to it, after #127 it deletes it.
Proposed fix
Remove only the link, never what it resolves to.
vm.removeFilealways resolves first, so there is no cheatcode path to this inforge-std 1.16.2. This repo already sets
ffi = trueinfoundry.toml, forexactly this neighbourhood — its symlink tests build links with
lnbecause nocheatcode creates one. The same door opens
unlink/rmon the link itself.Whatever shape it takes, the behaviour to land is:
pathis removed; its target is untouched, byte for byte.pathis removed (already true; keep it).pathis removed (already true; keep it).pathstill reverts rather than being cleared (already true).Each of those wants a test, and the live-symlink one must assert the target's
contents survive, which is the assertion no current test makes:
testBuildFileForContractReplacesLiveSymlinkasserts only thatpathends as aregular file, which is why the deletion passed review twice.
Also worth deciding while here
Whether a live symlink at a generated path should be replaced at all, or
refused. Refusing is the more conservative contract for a build-time library
that writes into a directory the consumer commits, and it makes the target
question moot. #127 chose replace; this issue does not reopen that, but the
choice is now visible in a way it was not.
Related
same overload; the PR does not itself cause it.