Reach the per-tag frozen-snapshot layout through LibFs - #137
Reach the per-tag frozen-snapshot layout through LibFs#137thedavidmeister wants to merge 3 commits into
Conversation
The org's release convention puts frozen deploy pin snapshots at `src/generated/<tag>/<Contract>.sol`, which `pathForContract` cannot produce: it refuses any name carrying a separator. `requireTag` accepts a single path segment drawn from the Solidity identifier alphabet with no rule about the first character, which is what admits the `<major>_<minor>_<patch>` tags the shared CI freezes. `dirForTag`, `pathForTaggedContract` and `buildFileForTaggedContract` carry that check, so neither a tag nor a contract name can reach past the two segments inside `GENERATED_DIR` that they name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sentinels land at a `.sol` path under `src/`, which the compiler and `forge fmt` both read. Written as formatted comments they are still content the generator never produces, so the assertions discriminate exactly as before, and a run that fails before its cleanup leaves a tree that still builds and still passes `forge fmt --check`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`main` added `isPresent`, which sees a symlink whose target does not exist where `vm.exists` reports the path as absent. The tagged write landed on this branch guarding its unlink with `vm.exists`, so it wrote through a dangling link to the link's target while its own NatSpec claimed the opposite. The tagged write now guards with `isPresent`, matching the untagged write main changed, and `testBuildFileForTaggedContractReplacesDanglingSymlink` asserts the guarantee at the tagged path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 58 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 (8)
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 |
Closes #78
What this adds
LibFscould not produce the path the org's release convention uses.requireContractNamerefuses any name carrying a/, andpathForContract'sNatSpec states every path it returns is a direct child of
GENERATED_DIR, sosrc/generated/<tag>/<Contract>.sol— the layoutfrozen-snapshots-append-onlyin the shared CI exists to police — was unreachable through the library.
Added to
src/lib/LibFs.sol:error InvalidTag(string tag)— a distinct error type, notInvalidContractName.requireTag(string tag)— the tag rule.dirForTag(string tag)→src/generated/<tag>.pathForTaggedContract(string tag, string contractName)→src/generated/<tag>/<Contract>.sol.buildFileForTaggedContract(Vm, address, string tag, string contractName, string body).pathForContractandbuildFileForContractare untouched.isPresent, whichmainadded while this branch was open, is also untouched — butbuildFileForTaggedContractnow uses it for the same reasonmain's untaggedwrite does. That is a semantic conflict a textual merge does not surface, so it
has its own section below.
Where the issue's proposed fix was wrong
The issue proposes
LibCodeGen.requireContractName(tag)and states "numeric tagssuch as
0_1_1are already identifiers under the existing rule". They arenot.
requireContractNameadmits a digit only ati > 0, so0_1_1reverts —and every release tag the org freezes opens with a digit. The proposed fix would
have compiled, passed a naive test, and rejected 100% of real frozen snapshot
tags: the exact thing the issue exists to make reachable.
rainix-static/src/frozen_snapshots.rs::is_tagis the authority on what a tag is:So
requireTagis a separate rule: at least one character, each of them anASCII letter, a digit,
_or$. That is the Solidity identifier alphabet withthe leading-digit restriction dropped, which is the only difference that matters —
a tag names a directory, not a declaration. It admits
0_1_1,12_0_255and therolling
candidatedirectory a deploy repo keeps beside the frozen ones, and itstill contains no
/, no\, no.and no NUL, so no tag is.or..and notag can add or remove a path segment.
Two further deviations from the issue's sketch:
0_1_1is a valid tag and an invalid contractname — the same string, two verdicts. Reporting both through
InvalidContractNamewould make a build failure unable to say which argumentwas wrong.
InvalidTagis exported fromLibFs.sol, where the rule lives.dirForTagas well.buildFileForTaggedContractneeds the directory tocreateDir, and the live consumer already hand-buildsstring.concat("src/generated/", deployTag())forcreateDirandreadDir.Putting the check on the directory too means every string this library hands a
caller for its own IO carries the confinement, not just the file path.
Also correcting the issue's factual claim about the live consumer: see the
"consumer migration" section below.
S01-Issuer/st0x.deploydoes notreimplement the header or the address-constant emitter — it calls
LibCodeGen.addressConstantStringandLibFs.buildFileForContractfromrain-sol-codegen-0.1.3, and the "old" header in its frozen files is thatversion's
filePrefix()output verbatim. The citation the issue gives for thereimplementation,
addressConstantString at script/BuildPointers.sol:54-60, doesnot point at one: at
st0x.deploy@mainthose lines are the NatSpec ofbuildContractPointers, and line 73 is a call into this library.Confinement, which is the point
test/src/lib/LibFs.pathForTaggedContract.t.solstates it over the whole inputdomain rather than as a list of escapes.
assertConfinedchecks one path forevery way out at once — it begins with
GENERATED_DIRfollowed by/, it addsexactly
separators(GENERATED_DIR) + 2, no segment is empty, and the only.anywhere is the appended extension.
..needs a dot, a deeper directory needs athird separator, and an absolute or doubled-separator path needs an empty
segment, so all of them are excluded together.
testPathForTaggedContractAcceptedArgumentsAreConfinedfuzzes each argument aseither arbitrary bytes or a value constructed from its alphabet (chosen by the
fuzzer), so the rejected domain is reached by the raw branches and the accepted
domain — which arbitrary bytes essentially never reach — by the constructed ones.
Nothing is assumed away: a pair that reverts proves confinement by producing no
path at all.
testPathForTaggedContractOneBadByteIsConfinedcovers theneighbourhood of the accepted domain, which uniform fuzzing never reaches.
QA
testPathForTaggedContractProducesTheOrgLayout,testPathForTaggedContractAcceptedArgumentsAreConfined,testPathForTaggedContractOneBadByteIsConfined,testRequireTagMatchesAlphabet,testRequireTagEveryLeadingByte,testRequireTagEveryTrailingByte,testRequireTagAcceptsEveryNumericReleaseTag,testRequireTagAcceptsLeadingDigitContractNameDoesNot,testDirForTagStructure,testPathForTaggedContractStructure,testBuildFileForTaggedContractCreatesTheTagDir,testBuildFileForTaggedContractLeavesOtherTagsAlone,testBuildFileForTaggedContractReplacesDanglingSymlink(+ 43 more, 56 new intotal) — none of the first 55 can pass on base, because
requireTag,dirForTag,pathForTaggedContractandbuildFileForTaggedContractdo notexist there. The failure was observed on base in the form the API allowed,
transcribed under "TDD" below:
[FAIL: InvalidContractName("0_1_1/StoxReceipt")].The 56th,
…ReplacesDanglingSymlink, is the merge withmainand has its ownobserved red against the tagged write as this branch first wrote it,
transcribed under "TDD: the semantic conflict with
main".mutation-probe,full matrix with the killing tests per mutant transcribed under "Mutation
matrix" below. 25/25 KILLED — 0 survived, 0 no-run, 0 harness errors, probe
exit code 0, baseline re-verified green (198 passed) before the pass, on the
merged tree.
nix develop -c forge test→ 198 passed, 0 failed, upfrom 142 on
mainat the merge point (b422d97);nix develop -c forge fmt --check→ exit 0. Both transcribed under "After the fix".LibCodeGenSlow.isTagSlow/tagFromSeedSlowdecide tag membership byscanning
SLOW_TAIL_ALPHABET, which is spelled out character by character,against
requireTag's range arithmetic — the two sides never share aderivation. Paths are asserted positionally against byte literals
(
"src/generated/","/",".sol") and against the argument bytes, never byre-running the library's own
string.concat. File content is asserted againstthe literal SPDX/pragma/header text and
address.codehash, not by callingfilePrefix()/bytecodeHashConstantString(). The tag shapes that must beaccepted come from
rainix-static/src/frozen_snapshots.rs::is_tagand fromS01-Issuer/st0x.deploy's committedsrc/generated/tree, not from thislibrary.
pathForTaggedContract, (B)buildFileForTaggedContractbesidebuildFileForContract, (C) the confinementinvariant that no tag or name traverses out of
GENERATED_DIR. Covered A, B, C.The issue's fourth claim — that
requireContractName(tag)suffices becausenumeric tags are already identifiers — is refuted rather than implemented; see
"Where the issue's proposed fix was wrong". The issue's fifth element,
migrating
S01-Issuer/st0x.deploy, is deliberately NOT done: reported under"Consumer migration" with the blocker that makes it a human ruling.
TDD: the failing test, before the fix
test/src/lib/LibFs.pathForTaggedContract.t.solfirst asserted the org layoutagainst the API as it stood:
nix develop -c forge test --match-path 'test/src/lib/LibFs.pathForTaggedContract.t.sol' -vvv:TDD: the semantic conflict with
mainmainlandedLibFs.isPresentwhile this branch was open.vm.existsanswersfor whatever a path resolves to, so it reports a symlink whose target does not
exist as absent;
isPresentalso asksvm.readLink, which answers for the pathitself.
mainswappedbuildFileForContract's unlink guard toisPresentforexactly that reason.
The merge is textually conflicted in
LibFs.solonly aroundisPresent'splacement, but the semantic conflict is elsewhere and no marker points at it:
buildFileForTaggedContract, added on this branch, guarded its unlink withvm.exists, so a dangling symlink at the tagged path was reported absent, theunlink was skipped, and
vm.writeFilefollowed the link and created thetarget — while the function's own NatSpec claimed a symlink there is
replaced. A textual merge resolves clean and ships that.
testBuildFileForTaggedContractReplacesDanglingSymlink(added tomain'stest/src/lib/LibFs.isPresent.t.sol) asserts the guarantee at the tagged path.Against the tagged write as this branch first had it:
The tagged write now guards with
isPresent(vm, path), matching the untaggedwrite, and its NatSpec is aligned with
main's wording. M25 in the matrix belowis that guard put back to
vm.exists; it is KILLED.After the fix
nix develop -c forge teston the merged tree — 198 passed, 0 failed, up from142 on
mainat the merge pointb422d97(56 new tests, 0 pre-existing testschanged):
nix develop -c forge fmt --check— clean, exit 0.Mutation matrix
Run with
mutation-probe(rainlanguage/adversarial-mutation-test) rather than ahand-rolled loop, so a zero-match "mutation" or a suite that never ran cannot read
as SURVIVED. Baseline verified green (198 passed) before any probe; every mutant's
target verified to match exactly once; every restore verified byte-exact. Suite
command per verdict:
rm -rf cache/fuzz/failures && nix develop -c forge test;proof-of-run regex
(\d+) tests passed, (\d+) failedread from forge's owntally. The fuzz-failure cache is dropped before every verdict because forge
replays counterexamples ahead of searching, which otherwise lets one mutant be
killed on evidence discovered under the previous one.
The probe names up to five killing tests per mutant; the first is transcribed
here. This column is regenerated from the pass that ran on the merged tree, not
carried over. Which tests kill a given mutant is not stable across runs — the
fuzz tests draw different inputs each pass, so a mutant killed by five tests one
pass can be killed by a different five the next. Only the verdict is stable.
requireTagdrops the empty-tag rejectiontestBuildFileForTaggedContractRejectsEmptyTagrequireTaguppercase range opens one early (admits@)testRequireTagEveryLeadingByterequireTaguppercase range closes one late (admits[)testRequireTagEveryLeadingByterequireTaglowercase range opens one early (admits`)testRequireTagEveryLeadingByterequireTaglowercase range closes one late (admits{)testRequireTagEveryLeadingByterequireTagdigit range opens one early (admits/)testRequireTagEveryLeadingByterequireTagdigit range closes one late (admits:)testRequireTagEveryLeadingByterequireTagdrops digits from the tag alphabettestDirForTagrequireTagcollapses into the contract-name rule (no leading digit)testDirForTagrequireTagdrops_from the tag alphabettestDirForTagrequireTagdrops$from the tag alphabettestBuildFileForTaggedContractBodyVerbatimrequireTagchecks only the first byte of the tagtestRequireTagErrorCarriesTheTagdirForTagdrops the tag checktestBuildFileForTaggedContractRejectsEmptyTagdirForTagdrops the separator before the tagtestBuildFileForTaggedContractReplacesDanglingSymlinkpathForTaggedContractdrops the contract-name checktestBuildFileForTaggedContractRejectsNameEscapespathForTaggedContractdrops the separator before the nametestBuildFileForTaggedContractWritesToPathForTaggedContractpathForTaggedContractdrops the appended extensiontestBuildFileForTaggedContractWritesToPathForTaggedContractpathForTaggedContractignores the tag, returns the untagged pathtestBuildFileForTaggedContractReplacesDanglingSymlinkpathForTaggedContractchecks the name before the tagtestPathForTaggedContractRejectsTheTagFirstbuildFileForTaggedContractdrops the tag-directory creationtestBuildFileForTaggedContractBodyVerbatimbuildFileForTaggedContractcreates the tag directory non-recursivelytestBuildFileForTaggedContractReplacesDanglingSymlinkbuildFileForTaggedContractcreates the generated dir, not the tag dirtestBuildFileForTaggedContractBodyVerbatimbuildFileForTaggedContractremoves the file without checking it existstestBuildFileForTaggedContractReplacesDanglingSymlinkbuildFileForTaggedContractwrites to the untagged pathtestBuildFileForTaggedContractReplacesDanglingSymlinkbuildFileForTaggedContractguards the unlink withvm.exists, notisPresenttestBuildFileForTaggedContractReplacesDanglingSymlinkM25 is the semantic conflict with
mainstated as a mutant: it is the code thisbranch had before the merge, and
…ReplacesDanglingSymlinkis the only test inthe whole 198 that kills it.
What M21 actually proves, and what it does not. It flips
recursivefromtruetofalseonvm.createDir(dirForTag(tag), true). A non-recursivecreateDirfails on two conditions — a missing parent, and a directory that isalready there — and all five tests that kill it in this pass kill it on the
second:
…ReusesAnExistingTagDirand…ReplacesExistingContentopen withvm.createDir(LibFs.dirForTag(tag), true),…ReplacesDanglingSymlinkmkdir -psthe tag directory to put the symlink in it, and
…Idempotentand…LeavesSiblingsAloneeach call the function twice for one tag. So the flag iscovered for the behaviour it has at this call site:
generating a second contract into a snapshot that already exists must not fail.
The parent-creating half is not reachable from
dirForTag, whose only possibleparent is
GENERATED_DIR— committed in this repo and written concurrently byevery sibling suite, so no test can make it absent. #112 reaches that half for
buildFileForContractby making the directory an argument; there is noequivalent for a directory the tag alone determines.
Why the first pass of this probe was not the matrix above. Two of these
tests wrote a sentinel that is not Solidity (
UNTAGGED, andSTALErepeated) toa
.solpath undersrc/, and removed it on the way out. A mutant that makesthe write revert skips that removal, so the residue survived into the next
mutant's compile and M21-M24 came back
NO-RUN— unscorable, and one keystrokeaway from reading as "survived". Both sentinels are now formatted Solidity
comments, which is a distinct content for the assertions either way, so a run
that dies part way through leaves a tree that still compiles and still passes
forge fmt --check. Checked, not assumed: after the pass-3 run above, with all32 residue paths the probe left in
src/generated/still in place,nix develop -c forge fmt --checkandnix develop -c forge buildboth exit 0.test/lib/LibFs.buildFileForContract.t.solonmainwrites itsSTALEsentinelthe same way; that one is untouched here and noted below.
Consumer migration — reported, not done
No consumer is touched by this PR.
S01-Issuer/st0x.deployis a live consumer ofthe per-tag layout — it is the one this was read against, at
main— andmigrating it is not a call-site swap. It is not established to be the only
one: every
gh search codequery tried for the layout returned zero results, sothe org-wide sweep never produced an answer either way, and there may be
consumers this did not find.
What is actually there today, correcting #78's description of it:
script/BuildPointers.solimportsrain-sol-codegen-0.1.3and callsLibFs.buildFileForContract(vm, deployed, string.concat(deployTag(), "/", name), …)and
LibCodeGen.addressConstantString(…). The pointer files are libraryoutput, not hand-rolled. The header in the frozen files
(
// THIS FILE IS AUTOGENERATED BY ./script/BuildPointers.solplus thecircular-dependency paragraph) is
filePrefix()atsol-v0.1.3verbatim.genV4/genCurrentemittingsrc/generated/LibProdDeployV4.solandLibProdDeployCurrent.solline by line withvm.writeLineunder their ownheader. That is an alias/index library, a shape this library has no surface for
at all, and nothing in this PR changes that.
What migrating the pointer files would involve:
pathForContractat0.1.3appended.pointers.sol; it appends.soltoday, andpathForTaggedContractinherits that. All 22 committed pointer files would berenamed. The 12 under
candidate/rename freely. The 10 under0_1_1/arefrozen, and
frozen_snapshots::checkdiffs with--no-renamesprecisely so arename surfaces as
D+Aand theDis flagged. So the frozen snapshotcannot be renamed without a human ruling on the append-only rule.
independently: the current
filePrefix()differs from0.1.3's, so anyregeneration rewrites those 10 files byte for byte. The append-only rule's
answer is that they are never regenerated — which leaves two header formats
coexisting in the repo, correctly.
import … from "./<tag>/<Name>.pointers.sol"linegenV4emits, andthe
pointerExistsprobe, carry the extension and move with it.buildFileForContract(vm, addr, string.concat(tag, "/", name), body)→buildFileForTaggedContract(vm, addr, tag, name, body), and the explicitvm.createDir(string.concat("src/generated/", deployTag()), true)becomesredundant.
candidateneeds no special handling: it is letters only, sorequireTagaccepts it.
The honest summary is that this PR makes the layout reachable, and a migration
of
st0x.deployis a separate piece of work whose first question — whether afrozen snapshot may be renamed — is a human ruling, not a code change.
Also found, not touched
buildFileForContract(vm, instance, dir, contractName, body)overload whosediris deliberately unchecked, on the reasoning thatfs_permissionsiswhat confines it. If both land,
LibFsoffers two ways to reachsrc/generated/<tag>/— one that checks the directory segment and one thatdoes not — and the unchecked one is the more convenient. Whether that is the
intended end state is a design call for a human, not something this PR should
settle on a sibling's branch. Flagged, not touched.
testRequireTagErrorCarriesTheTagmay be the testmainjust deleted. Inthe same window,
mainremovedtestRequireContractNameErrorCarriesTheNamefrom
test/lib/LibCodeGen.requireContractName.t.solon the reasoning thatassertRejectedalready asserts the whole error — selector and argument — soevery rejection in the file pins that claim and a separate test for it is a
second surface for one fact. This branch's
LibFs.requireTag.t.solhas thesame shape: its
assertRejectedpinsInvalidTag.selector, tag, andtestRequireTagErrorCarriesTheTagre-states it for one input. It is the onlymutant killer list in the pass-3 matrix below that names it, M12, and that
mutant is killed by four other tests as well. It is left in place because adopting a
sibling's convention change is a reviewer's call, not a merge resolution;
say the word and it goes.
test/lib/LibFs.buildFileForContract.t.solonmainwrites aSTALEsentinelthat is not Solidity to
src/generated/<name>.soland removes it on the wayout, so a run that fails before the removal leaves a tree that neither compiles
nor passes
forge fmt --check, and the original failure is then buried under aparse error. That is the same defect the two sentinels in this PR's own tests
had, and it is what made the first pass of this probe unscorable. It is a
sibling's file, so it is reported here rather than changed.