Uh oh!
There was an error while loading. Please reload this page.
LibFs: require a Solidity identifier, bootstrap the generated dir, document the unlink - #53
Conversation
`buildFileForContract` refuses a contract name that is not a Solidity identifier, so the file it writes is always a single path segment directly inside `src/generated`. `LibContractName` holds the check so the same definition is available to any other caller that interpolates a name. `src/generated` is created before the write, so a repo generating for the first time does not need it committed already. The remove before the write is documented for what it does: a symlink at the path is replaced rather than written through, and the path does not exist between the unlink and the write. Idempotency comes from the write replacing the whole file. Closes#38Closes#40Closes#41 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`vm.expectRevert` failing leaves whatever the call wrote on disk, so a test that only asserts the path is empty inherits the previous run's leftovers. Removing the path first makes the assertion about this run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Warning Review limit reached
Next review available in:15 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 (6)
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 |
#54 landed `requireContractName` and `InvalidContractName` in LibCodeGen on main, so `LibContractName` is a second definition of the same rule and goes. `LibFs` already imports `LibCodeGen`, so the check arrives with an import that was already there. The coverage that `LibContractName`'s own suite held and `LibCodeGen`'s does not is folded into `test/lib/LibCodeGen.requireContractName.t.sol`: the exhaustive 256 byte sweeps in the leading and trailing positions, fuzzed agreement with an alphabet written out character by character rather than with a second copy of the library's own range arithmetic, fuzzed acceptance of constructed identifiers, and fuzzed rejection of a single bad byte anywhere in an otherwise valid name. The alphabets and the seed to identifier fold move to `LibCodeGenSlow` alongside the definition they now reference.
thedavidmeister
commented
Aug 16, 2026
Reviewed 5a4a1b7: ready — Closes #38, #40, #41. The identifier check sits in The three fuzz properties #36 landed are kept, and kept meaningful.
22 mutants, 20 killed, 2 survived, 0 no-run, 0 harness errors, and both survivors are named rather than absorbed:
The placement mutant is worth recording: moving the check back to #41 keeps the unlink and deletes the idempotency claim — the NatSpec now says what it actually does, a symlink at the path replaced rather than written through, with the path absent between unlink and write. It stays because with the name confined to one segment it is the last thing preventing a write landing outside 110 to 123 tests, 16 suites, 0 failures. All four checks green. CodeRabbit reports |
Uh oh!
There was an error while loading. Please reload this page.
Closes#38, closes#40, closes#41.
Three issues in the write path, all about what it is allowed to write and
where, so they are one change.
mainmerged in at72fddf4, which landed #54.#38 — a contract name has to be a Solidity identifier
Decision: the check is inside
pathForContract, not at the write.pathForContractreverts unlesscontractNameis a Solidity identifier, soevery path this library can produce is a direct child of
GENERATED_DIR. Thereis no name for which the library returns a path at all without returning a safe
one, and a caller that takes the path and does its own IO with it gets the same
confinement
buildFileForContractdoes. All three failures in the issue arerefused:
""— the path issrc/generated/.sol, valid generated Solidity at a nameno compiler picks up as a contract file and that
lshides, written by abuild that reports success.
sub/Foo— a subdirectory, which theread-writegrant onsrc/generatedadmits, and which dies on a bare
No such file or directorywhen thesubdirectory is not there.
../../ESCAPED— outside the tree entirely under the{ access = "read-write", path = "." }grant consumers commonly write.An earlier revision of this PR put the check at the write instead, on the
argument that every harm in the issue is a write and that a name which never
reaches disk is just a string. That is the weaker guarantee and it is reversed
here. The issue is titled for
pathForContract, not for the write; the writeis only the first consumer of a bad path, not the only possible one.
The cost that argument gave for the stronger placement was that three fuzz
properties landed by #36 —
testPathForContractStructure,testPathForContractDistinctNamesDistinctPathsandtestPathForContractIsRelative— quantify over arbitrary strings, and a randomstring is essentially never an identifier, so
vm.assumecould not besatisfied. That is a reason to fix the generators, not a reason to weaken the
code. All three keep the property they were landed for — that a name is never
quoted, escaped, trimmed, case folded or truncated on its way into the path —
and now assert it over names CONSTRUCTED from the identifier alphabet
(
LibCodeGenSlow.nameFromSeedSlow) rather than over strings filtered byvm.assume. Constructing is what keeps them non-vacuous: filtering for anidentifier would have left them proving the property over almost nothing.
The residual an earlier revision recorded —
pathForContract("..")returningsrc/generated/../.sol— no longer exists. That call reverts.Where the check lives.#54 landed
LibCodeGen.requireContractNameandInvalidContractNameonmainwhile this branch was open, fordescribedByMetaHashConstantString's ownnameparameter. This branch'sLibContractNamewas a second definition of the same rule, so it is deleted andLibFscallsLibCodeGen.requireContractName. There is no import cycle:LibFsalready importsLibCodeGenforfilePrefixandbytecodeHashConstantString, so the check arrives with an import that wasalready there. One definition of what a contract name is, used by both the path
and the meta hash constant.
#40 — first generation in a repo that has never generated
vm.createDir(GENERATED_DIR, true)before the write. That iscreate_dir_all,so it is a no-op when the directory is already there, and it is allowed by the
read-writegrant onsrc/generatedthat a consumer needs in order to write atall — checked against this repo's own
fs_permissions, not assumed.#41 — decision: keep the removal, document what it does
The docstring said the removal "ensures idempotent file generation". It does
not, and that claim is gone:
vm.writeFiletruncates, so the bytes at the pathare identical with and without the removal. What idempotency there is comes from
the whole file being written on every call, and that is what the docstring says
now.
The line stays, for the reason that is actually true.
removeFileunlinks, so asymlink at the path is replaced by a regular file rather than followed and
written through to its target. With the name now confined to a single path
segment, that unlink is the only thing left between
buildFileForContractand awrite that lands outside
src/generated: foundry normalises paths lexicallybefore the permission check, so a symlink inside a granted directory is not
caught by the grant. Deleting the line closes the lexical half of #38 and leaves
the symlink half open, to save one cheatcode call.
The cost the issue names is real and is now documented instead of argued away:
between the unlink and the write the path does not exist, so a write that fails
takes the previous content with it. What is at that path is a generated file
that the build reproduces from source and that consumers commit, so that window
costs a rerun.
The argument against keeping it is that the docstring shows it was written for
idempotency rather than for symlinks, which makes the symlink guarantee a
justification found afterwards — and keeping a line because it happens to be
there is how a mistake gets entrenched. It survives that on its own merits: the
guarantee is real, it is the other half of the invariant the rest of this PR
establishes, and it is now stated in the NatSpec, so the next reader deletes it
deliberately or not at all.
Together, #38 and #41 make one statement that neither makes alone:
buildFileForContractwrites only to a regular file that is a direct child ofsrc/generated.Tests
110 → 123 over the merged
main.test/lib/LibFs.t.solis where the guarantee now lives, and it is split bydomain rather than quantified over arbitrary strings. Over the ACCEPTED domain,
names are constructed from the identifier alphabet: the three #36 properties as
above, plus
testPathForContractAcceptedNamesStayInGeneratedDir, which countsseparators and dots in the path so no accepted name can reach a subdirectory, a
parent directory or a hidden file. Over the REJECTED domain, arbitrary bytes are
exactly the right generator, because that is what the rejected domain is:
testPathForContractRejectsEveryNonIdentifierNamefuzzes it, andtestPathForContractRejectsNamedEscapespins the strings from the issue plusthe other shapes that stop the path being a single segment.
test/lib/LibFs.buildFileForContract.t.solkeeps the refusals at the write.They are not duplicates of the above: what they assert is that the write
inherits the refusal and that nothing appears on disk when it does, each of them
removing the path first so it establishes its own precondition rather than
assuming one.
src/lib/LibContractName.soland its suite are deleted in favour of #54'sdefinition. The coverage that suite held and
test/lib/LibCodeGen.requireContractName.t.soldid not is folded into the surviving file rather than dropped: exhaustive sweeps
over all 256 bytes in the leading position and again in a trailing position,
fuzzed agreement with an alphabet spelled out character by character in
LibCodeGenSlow(the file's existing fuzz oracle inlines the library's ownrange arithmetic, so it moves when the library moves), fuzzed acceptance of
constructed identifiers so the accepted half of the domain is exercised at all,
and fuzzed rejection when a single byte anywhere in an otherwise valid name is
replaced.
Mutation pass
22 mutants — 8 on
LibFs, 14 onLibCodeGen.requireContractName— applied withmutation-probe, classifying from forge's ownN tests passed, N failedtally.20 killed, 2 survived, 0 no-run, 0 harness errors. The old matrix does not
carry over: moving the check changed what each mutant probes, and
mainmovedunder the branch. One killer is named per mutant below; most were killed by
several, and the probe caps its report at five.
src/lib/LibFs.sol,line -> mutation -> killing test:GENERATED_DIR = "src/generated"->"src/generated_moved"->testBuildFileForContractCommittedArtifactIsCurrentrequireContractName(contractName)->requireContractName("Placeholder")->
testBuildFileForContractRejectsEmptyNamepathForContractand intobuildFileForContract->testPathForContractRejectsNamedEscapesconcat(GENERATED_DIR, "/", name, ".sol")->concat(GENERATED_DIR, name, ".sol")->
testBuildFileForContractCommittedArtifactIsCurrentvm.createDir(GENERATED_DIR, true)->vm.createDir(GENERATED_DIR, false)->
testBuildFileForContractFreshPathvm.createDir(GENERATED_DIR, true)->vm.isDir(GENERATED_DIR)-> SURVIVEDif (vm.exists(path))->if (!vm.exists(path))->testBuildFileForContractFreshPathvm.removeFile(path)->vm.exists(path)-> SURVIVEDThe third of those is the one this PR turns on, so it is worth stating what
killed it and what did not. Its two killers are
testPathForContractRejectsNamedEscapesandtestPathForContractRejectsEveryNonIdentifierName, and nothing else in 123tests. Every refusal test on the write still passes under it, because under it
the write still refuses. The placement is held by exactly the coverage added for
it, and by nothing that was already there.
src/lib/LibCodeGen.sol(requireContractName, landed by #54; mutated becausethis PR makes
LibFsdepend on it and adds coverage over it):if (nameBytes.length == 0)->if (false)->testBuildFileForContractRejectsEmptyNamei < nameBytes.length->i < nameBytes.length - 1->testRequireContractNameEveryTrailingByteuint256 i = 0->uint256 i = 1->testRequireContractNameEveryLeadingBytechar >= 0x41->char >= 0x40->testRequireContractNameEveryLeadingBytechar <= 0x5A->char <= 0x5B->testRequireContractNameMatchesAlphabetchar >= 0x61->char >= 0x62->testDescribedByMetaHashConstantStringchar <= 0x7A->char <= 0x7B->testRequireContractNameEveryTrailingBytechar >= 0x30->char >= 0x31->testPathForContractStructurechar <= 0x39->char <= 0x3A->testPathForContractRejectsEveryNonIdentifierNamechar == 0x5F->char == 0x5E->testRequireContractNameAcceptsGeneratedIdentifierschar == 0x24->char == 0x25->testRequireContractNameAcceptsGeneratedIdentifiersisDigit && i > 0->isDigit && i >= 0->testRequireContractNameEveryLeadingByteisDigit && i > 1->testPathForContractStructure!(isLetter || isUnderscoreOrDollar || (isDigit && i > 0))->!(isDigit && i > 0)->
testDescribedByMetaHashConstantStringTwo of those are the direct evidence that rewriting the three #36 generators
kept them meaningful rather than quietly retiring them.
char >= 0x31andisDigit && i > 1both shrink the accepted alphabet, and both are killed bytestPathForContractStructure,testPathForContractIsRelativeandtestPathForContractDistinctNamesDistinctPaths— because those now build namescontaining digits and feed them to a function that must accept them. Filtered
through
vm.assumeinstead, they would have been vacuous and killed nothing.Survivor 1 — deleting
vm.createDirThe only state that tells it apart is
src/generatedbeing absent, and thisrepo commits that directory. Producing the state inside
forge testmeansdeleting it mid-run, which races every other suite (forge runs suites in
parallel) and takes the committed artifact that
testBuildFileForContractCommittedArtifactIsCurrentreads with it. Reported asa survivor rather than covered by a flaky test.
Checked by hand instead, and this is the evidence for #40 rather than a test.
With
src/generatedmoved out of the working tree,forge test --match-test testBuildFileForContractFreshPathpasses as written.With the same directory absent and only that line replaced by the surviving
mutant, it fails with the exact error from the issue:
vm.writeFile: failed to open file ".../src/generated/LibFsBuildFresh.sol": No such file or directory (os error 2).Survivor 2 — deleting
vm.removeFileThe same mutant that survived the campaign, and the subject of #41. Nothing
reachable through cheatcodes distinguishes remove-then-write from write-alone:
writeFiletruncates, so the bytes are identical, and the symlink behaviour theremoval actually provides cannot be set up without
ffi, which this repo doesnot enable. Left surviving rather than killed with a test asserting something the
suite cannot see. The guard around it is a different matter and is covered —
testBuildFileForContractFreshPathkills the inversion, becauseremoveFilereverts on a path that is not there.
Harness note
The probe's suite command restores
src/generatedfrom git before each run.Without that, the first mutant that makes an fs-writing test fail part way leaves
its file behind — a stale fixture, or a half generated file — the next
forge testfails to compile it, and every later verdict isNO-RUNdressed upas a result. The two tests that assert nothing was written also remove the path
first, so they establish their precondition rather than assume it.
QA
forge fmt --checkclean;
forge lintreports onlyunsafe-typecast, onuint8(i)insidefor (i = 0; i < 256; i++)sweeps — the same rule, unsuppressed, thatmain's ownLibHexString.bytesToHex.t.solandLibCodeGen.requireContractName.t.solalready report. Baseline on the mergedmain(72fddf4) was 110 tests, 16 suites, 0 failures, measured on a cleancheckout of that commit before any probing.
now depends on. 20 killed, 2 survived, 0 no-run, 0 harness errors; both
survivors are named above with why no test can hold them, and one of the two is
the subject of buildFileForContract's remove-then-write is unobservable, and its NatSpec credits it with idempotency that writeFile already provides #41 itself.
LibFs.pathForContract accepts any string as a contract name: empty writes a hidden dotfile, a slash targets a subdirectory, and .. escapes under a normal fs_permissions grant #38, not the implementation. The reference alphabet in
LibCodeGenSlowisspelled out character by character while the library decides by range
comparison, so an off-by-one at either end of a range is a disagreement rather
than a change that moves both sides at once. buildFileForContract cannot bootstrap a repo that has never generated: vm.writeFile does not create src/generated #40 is judged against the issue's
own reproduction, run both ways, above.
that is not a Solidity identifier, and it is covered as a set at the path — all
256 bytes in both positions, fuzzed agreement over arbitrary byte strings, and
fuzzed rejection when a single byte anywhere in an otherwise valid name is
replaced.
src/generated/CodeGennable.solis untouched andtestBuildFileForContractCommittedArtifactIsCurrentstill passes, so nothinghere moved
filePrefixor the bytecode hash constant.