Cover buildFileForContract's unlink with a hard link, and enable ffi to reach it - #109
Cover buildFileForContract's unlink with a hard link, and enable ffi to reach it#109thedavidmeister wants to merge 3 commits into
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 53 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 (2)
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 |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Closing unmerged. Both halves of this PR are already on
The unlink block is already covered. #110 added The reason the issue's own proposed test could not work at the time this PR was The hard-link test also pins a property |
Closes #66
What changed
One test,
testBuildFileForContractUnlinksBeforeWriting, intest/lib/LibFs.buildFileForContract.t.sol, plusffi = trueinfoundry.tomlso it can create the link it needs.src/lib/LibFs.solisuntouched.
The test puts a hard link at the generated path: two names for one file. It
writes a sentinel through the other name, asserts the path reads it back (so
the two names are proven to be one file, not two with equal bytes), then runs
buildFileForContract. Afterwards the other name still holds the sentinel andthe path holds the generated file. Without the unlink,
vm.writeFiletruncatesand rewrites the file that is already at the path — which is the same file the
other name refers to — so the sentinel is replaced by the generated bytes and
the test fails.
The issue's proposed fix asserts behaviour this library does not have
#66 proposed a dangling-symlink test. Measured on
935c725, withffienabled, that test does not pass and could not cover the block if it did:
vm.existsresolves symlinks, so a dangling link at the path reportsabsent and the unlink block never runs.
vm.writeFilethen follows the linkand creates its target. The issue's
assertFalse(vm.exists(target))is falseon this tree — it asserts a fix, not current behaviour, and the block it is
meant to cover is not reached on that input at all.
vm.removeFileresolves thelink and removes its target, leaving the link in place and dangling; the
following
vm.writeFilethen recreates that same target. With and withoutthe unlink block, the generated bytes end up at the link's target and the
path is still a symlink. Measured directly: after
vm.removeFile(<symlink>),readDirstill reports the path withisSymlink == truewhile the target isgone.
A hard link avoids both: no resolution happens,
vm.removeFileunlinks thepath itself, and the second name is a witness for whether the write landed on a
new file or through the old one.
Decision:
ffi = trueinfoundry.tomlforge-std 1.16.1 can read a link (
readLink) but creates neither kind, andthere is no cheatcode that makes a file un-writable, so no in-process route
distinguishes "unlink then write" from "write". The block is either covered by
shelling out or not covered.
Build cost. One config line and three lines of
string[]in the test.Carrying cost. The grant is repo-wide: every test and script here can run
arbitrary commands as whoever runs
forge test, including a reviewer running acontributor's branch locally. Not mitigated by config — foundry has no per-test
ffi grant. Bounded by what is actually in the blast radius:
forge testrunsonly this repo's
test/, not the one dependency's (forge-std 1.16.1), andfoundry.tomldoes not propagate to consumers of the published soldeerpackage, so no downstream repo inherits this. Exactly one call site today, and
C1in the matrix below shows the suite names it when the grant is removed.Removal cost. Delete the line and the one test; the unlink block goes back
to uncovered. There is nothing else to unpick.
Routes rejected.
forge test --ffi—rainix-sol-test.yamlruns plainforge test -vvv, sothis needs a change in rainix's reusable workflow, which every consumer repo
pins
@main. That converts a one-repo grant into an org-wide grant on everySolidity suite. Strictly wider than the config line, and shared CI is not
this repo's to change.
[profile.ffi]+FOUNDRY_PROFILE=ffi— CI runs the default profile, so thetest would revert in CI. Coverage that does not run in CI is not coverage.
recreate it, so it works once and leaves the tree dirty.
discriminates only on a cheatcode's error string, and never exercises the
write.
Found, not fixed, not filed
Both are in
buildFileForContract's body and docstring, which #61 isrewriting, so they are left to that change rather than resolved here. Measured
on
935c725:LibFs.sol:49-51states a guarantee the library does not provide. "asymlink there is replaced by a regular file rather than written through to
its target" is false in every case, because
vm.removeFileresolves thelink and removes the target. End to end over a live symlink: the path is
still a symlink afterwards and the generated file is at its target.
unlink is skipped (
vm.existsresolves the link), andvm.writeFilecreates the link's target — which the link can point anywhere, including
outside
src/generated, sincefs_permissionsis checked against the pathstring.
#64 owns
vm.createDircoverage and is untouched here.QA
testBuildFileForContractUnlinksBeforeWriting- failson base behaviour (verified by deleting the unlink block on this branch and
running the full suite: 1 failed / 135, output quoted below; with the test
excluded the same mutant leaves 134/134 green, which is the finding).
LibFs.sol:68-71wholeif (vm.exists(path)) { vm.removeFile(path); }deleted -> killed bytestBuildFileForContractUnlinksBeforeWriting;LibFs.sol:70vm.removeFile(path);deleted -> killed by the same test;LibFs.sol:68guard dropped (unconditional
removeFile) -> killed bytestBuildFileForContractFreshPath+ 7 siblings;LibFs.sol:68guardnegated to
!vm.exists(path)-> killed by those 8 plus the new test;foundry.tomlffi = truedeleted -> killed by the new test(
vm.ffi: FFI is disabled). Full table below."SHARED"written through the other name of the hardlink, and
expectedFile(instance, body), which the test file rebuilds fromliteral text plus
address.codehashrather than by callingLibCodeGen.Neither value comes from the code under test.
LibFs.sol:68-71and namesffias a decision to make. Covered: the blockis now killed by a test (M1, M2 below), and the
ffidecision is taken withits ledger above. Not covered, deliberately: the issue's dangling-symlink
test asserts behaviour this library does not have and never reaches the
block — see the two measured defects under "Found, not fixed, not filed",
which belong to LibFs.buildFileForContract unlinks the existing file before the content is computed, so a failed build destroys the previously generated file #61's rewrite of these lines.
vm.createDircoverage is vm.createDir(GENERATED_DIR, true) in LibFs.buildFileForContract has no test: deleting it leaves 134/134 passing #64.Evidence
Full suite before, on a clean
935c725clone:Ran 16 test suites: 134 tests passed, 0 failed, 0 skipped (134 total).Finding reproduced. Unlink block deleted, new test excluded:
Ran 16 test suites: 134 tests passed, 0 failed, 0 skipped (134 total)—the mutant that motivated LibFs.buildFileForContract's unlink block has no test, and no test in this repo can currently reach it #66 survives the suite as it stands.
Failing before. Same mutant, new test included:
Ran 16 test suites: 134 tests passed, 1 failed, 0 skipped (135 total)Passing after. Source restored,
nix develop -c forge test:Ran 16 test suites in 2.26s: 135 tests passed, 0 failed, 0 skipped (135 total).Mutation matrix (
nix develop -c forge test, full suite each time; everyrow printed a
Ran 16 test suitesline, so every row actually ran):if (vm.exists(path)) { vm.removeFile(path); }deletedvm.removeFile(path);deletedremoveFileunconditionalvm.removeFile: … No such file or directory); already covered bytestBuildFileForContractFreshPathif (!vm.exists(path))ffi = trueremoved fromfoundry.tomlvm.ffi: FFI is disabled; the grant is load bearing and has exactly one usernix develop -c forge fmt --check— clean, exit 0.nix develop -c slither .—9 contracts with 98 detectors, 0 result(s) found.CI on this branch, GitHub Actions
rainix / test / test(ubuntu-latest, nixsol-shell, plain
forge test -vvvfrom the reusable — no workflow change):[PASS] testBuildFileForContractUnlinksBeforeWriting() (gas: 72135)andRan 16 test suites in 3.31s: 135 tests passed, 0 failed, 0 skipped (135 total tests).ffiandlnboth work there as configured.CodeRabbit: rate limited, not reviewed. Its comment reads "Review limit
reached … Next review available in: 54 minutes", and the PR has zero review
threads. Its green check is the absence of a review, not a passed one.