Unlink a symlink with no target before writing the generated file - #110
Conversation
vm.exists answers for what a path resolves to, so a symlink with no target read as nothing at the path and the write followed the link. LibFs.isPresent answers for the path itself and is what the write now asks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Places the test at the mirror path for src/lib/LibFs.sol and ties the link under test to pathForContract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 55 minutes Limit details: You’ve used all 1 included review currently available under your plan. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
LibFs.buildFileForContractremoves whatever is at the generated path before itwrites, so that a symlink at that path is replaced by a regular file rather than
written through to its target. It asked
vm.exists, which answers for what apath resolves to: a symlink whose target does not exist resolves to nothing and
reports absent, so the removal was skipped and
vm.writeFilefollowed the linkand created the target instead of a file at the path.
LibFs.isPresentnow answers that question about the path itself.vm.readLinkreverts unless the path is a symlink, so it sees exactly the casevm.existsdoes not, and the two together answer true for a file, a directory,a symlink that resolves and a symlink that does not.
buildFileForContractasksisPresent, and its NatSpec now states the symlink-with-no-target case that thebehaviour covers.
From the whole-repo audit at
7aa85a4. There is no issue attached to this one.ffi = trueinfoundry.tomlforge-std 1.16.1 has no cheatcode that creates a symlink, so nothing could put
one in front of this code at all — on
main, deleting the entire removal blockleaves the suite green. The tests here build symlinks with
lnand read themback with
readlink, both of which needffi = true.The ledger for that line:
forge test --ffi, isnot available to this repo: CI runs rainix's
rainix-solreusable, which runsplain
forge test, and shared CI is rainix's to change.that run it are
on: [push]withsecrets: inherit, so the exposure isbounded by push access — which already carries the ability to edit those
workflows.
foundry.tomlis listed in.soldeerignore, so no consumer of thepublished package inherits the setting.
raindexandrain.math.floatalready set
ffi = truein their default profile.tryFfihelpers in one test file are what changes, and the config line goes with them.
Nothing under
src/uses ffi.Every path the tests hand to a command is built inside the test from a bare
name, so no test in the file can name a path outside
src/generated. Setup andteardown use
rm -rfrather than the cheatcodes, because a cleanup that leanedon the behaviour under test would leave the tree dirty exactly when the test
fails.
Placement, and what is left to issue 66
The new file is at
test/src/lib/LibFs.isPresent.t.sol, the mirror path forsrc/lib/LibFs.sol, rather than intest/lib/next to the 16 files that#56 moves there. It touches
no existing file under
test/, so it neither conflicts with that PR nor withthe other open PRs in the same tree.
#66 owns the removal
block's coverage in general. This PR covers the
symlink-with-no-target case end to end, and
isPresentover its whole domain.Left there: the write's behaviour for a symlink that does resolve (covered
here only for
isPresent's own answer, not throughbuildFileForContract), andthe block's other claims.
Issue 66's own fix proposes
ffi = trueas well, so whichever of the two landssecond gets a one-line conflict in
foundry.tomland should keep the survivingcomment rather than the second copy of the setting.
QA
testBuildFileForContractReplacesDanglingSymlink,testIsPresentDanglingSymlink,testIsPresentNothing,testIsPresentFile,testIsPresentDirectory,testIsPresentSymlink. The first was written andrun against unmodified
LibFsand failed there —VM::exists("src/generated/LibFsIsPresentDangling.sol") → false, thenVM::writeFileto that path, thenVM::exists("src/generated/LibFsIsPresentDanglingTarget.txt") → true, giving[FAIL: the write followed the link to its target] testBuildFileForContractReplacesDanglingSymlink()andSuite result: FAILED. 0 passed; 1 failed. On disk after that run the path was still a symlink andthe 382-byte generated file was at the target. Its preconditions passed in
that same run, so the link was really created and really dangling:
lnexitcode 0,
readlinkexit code 0,vm.existsfalse for both the link and itstarget. The other five could not run against base — the function they call
did not exist — and each is instead pinned by the mutants below. After the
fix:
Ran 17 test suites: 140 tests passed, 0 failed, 0 skipped, against 134on
main, with no existing test changed, andgit status --porcelainafterthe run lists only the files this PR changes.
git diff --numstat(1 line) before the run, full suite run, thengit checkout --torestore. Every run reports 140 total tests, so the suite ran in all four:
isPresent:if (vm.exists(path))→if (false)— killed, 3 failed(
testIsPresentFile,testIsPresentDirectory,testIsPresentSymlink).isPresent:trybranch returnsfalse— killed, 2 failed(
testIsPresentDanglingSymlink,testBuildFileForContractReplacesDanglingSymlink).isPresent:catchbranch returnstrue— killed, 10 failed(
testIsPresentNothing,testBuildFileForContractReplacesDanglingSymlink,and 8 pre-existing tests, all reverting in
vm.removeFileon a path holdingnothing).
buildFileForContract:isPresent(vm, path)→vm.exists(path), i.e. thebehaviour before this PR — killed, 1 failed
(
testBuildFileForContractReplacesDanglingSymlink).ln,readlinkandrmrather than by thecheatcodes the library calls.
vm.readLinkis whatisPresentuses, so thetests never assert with it; whether a path is a symlink is asked of the
readlinkbinary, and whether the write followed the link is asked as whetherthe target file exists at all. The generated content is compared against a
second contract written to a path that held nothing, so the claim is that the
two cases produce the same file rather than that particular bytes appear.
vm.existsanswering for a path'starget instead of the path. The category is every kind of thing that can
occupy a path, so
isPresentis asserted over all of them — nothing, a file,a directory, a symlink that resolves, a symlink that does not — not only the
dangling case that motivated it, and the write is asserted end to end for that
case. Left to LibFs.buildFileForContract's unlink block has no test, and no test in this repo can currently reach it #66 as
described above.
forge fmt --checkexits 0.forge lintover the two changed filesreports nothing.
reuse lintis compliant, 48/48 files.slither .reports0 findings: it read the ignored
vm.readLinkreturn asunused-return,which is what the call is for, so that line carries the org's
//slither-disable-next-line unused-returnand a comment saying why thetarget is not read.