Skip to content

Reach the per-tag frozen-snapshot layout through LibFs - #137

Merged
thedavidmeister merged 13 commits into
mainfrom
2026-08-16-issue-78
Aug 17, 2026
Merged

Reach the per-tag frozen-snapshot layout through LibFs#137
thedavidmeister merged 13 commits into
mainfrom
2026-08-16-issue-78

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes #78

What this adds

LibFs could not produce the path the org's release convention uses.
requireIdentifier refuses any name carrying a /, and pathForContract's
NatSpec states every path it returns is a direct child of GENERATED_DIR, so
src/generated/<tag>/<Contract>.sol — the layout frozen-snapshots-append-only
in 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, not InvalidIdentifier.
  • 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 spdxLicenseIdentifier, string copyrightText, string body).

pathForContract and buildFileForContract are untouched: across the whole
branch, the only lines this PR removes from main are three lines of one
contract-level docstring in test/src/lib/LibFs.isPresent.t.sol, reworded
because that contract now asserts against both writes. Everything else is
addition.

Built on #112's dir overload, not beside it

This branch was written against the flat LibFs that preceded #112 and carried
its own copy of the write plumbing. #112 has since landed a private
pathForContractIn(dir, contractName) and a dir-carrying
buildFileForContract, which is the same plumbing with the directory lifted into
a parameter; #135 then gave that overload the caller's licence and copyright. The
tagged functions are built on those rather than beside them, and are one line
each:

function pathForTaggedContract(string memory tag, string memory contractName)
    internal
    pure
    returns (string memory)
{
    return pathForContractIn(dirForTag(tag), contractName);
}

function buildFileForTaggedContract(
    Vm vm,
    address instance,
    string memory tag,
    string memory contractName,
    string memory spdxLicenseIdentifier,
    string memory copyrightText,
    string memory body
) internal {
    buildFileForContract(vm, instance, dirForTag(tag), contractName, spdxLicenseIdentifier, copyrightText, body);
}

Collapsed out of this PR by that, each of these now existing once in the repo
rather than twice:

  • LibCodeGen.requireIdentifier(contractName) and the
    dir + "/" + contractName + ".sol" interpolation.
  • vm.createDir(dir, true).
  • The isPresent(vm, path) unlink guard and vm.removeFile(path).
  • The vm.writeFile of filePrefix(…) + bytecodeHashConstantString(vm, instance) + body.
  • Three //forge-lint: disable-next-line(unsafe-cheatcode) suppressions.

What is left is the only thing that was ever about tags: the directory is not
the caller's to choose, because dirForTag is where it comes from and
dirForTag refuses anything that is not one path segment from the tag alphabet.
Solidity evaluates dirForTag(tag) before either call, so the tag is still
checked before the name and before any cheatcode:
testPathForTaggedContractRejectsTheTagFirst pins the first half and
testBuildFileForTaggedContractRejectsNameEscapes — which asserts the tag
directory is not created for a rejected name — pins the second.

This also settles what this PR previously flagged as an unresolved overlap with
#112. There is no longer a checked and an unchecked way to reach
src/generated/<tag>/: the tagged pair is the dir overload, applied to a
directory the library derived. The overload's own dir parameter is still
unchecked and still internal, which is #112's stated design — fs_permissions
is what confines an arbitrary dir, and buildFileForTaggedContract is the way
in that does not need it to.

setUp on the tagged write's suite

src/generated/ holds no committed file, vm.writeFile does not create a
missing parent, and testBuildFileForTaggedContractLeavesTheUntaggedFileAlone
writes its sentinel straight to src/generated/LibFsTaggedUntagged.sol rather
than through the library. Without a setUp that test passes only when some
other test in the same contract created the directory first, which is a
statement about execution order, not about the code under test. Measured on this
tree with rm -rf src/generated and the setUp removed:

$ nix develop -c forge test --match-path 'test/src/lib/LibFs.buildFileForTaggedContract.t.sol' \
    --match-test 'testBuildFileForTaggedContractLeavesTheUntaggedFileAlone'
[FAIL: vm.writeFile: failed to open file
"…/src/generated/LibFsTaggedUntagged.sol": No such file or directory (os error 2)]
testBuildFileForTaggedContractLeavesTheUntaggedFileAlone() (gas: 55155)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 598.96µs

LibFsBuildFileForTaggedContractTest now creates GENERATED_DIR in setUp,
the same way LibFsIsPresentTest and LibFsBuildFileForContractTest on main
already do and for the same reason. With it, rm -rf src/generated followed by
the whole file filtered on its own is 18 passed, 0 failed.

The other three new test contracts need nothing: requireTag, dirForTag and
pathForTaggedContract are pure and touch no filesystem, and the new test in
LibFs.isPresent.t.sol mkdir -ps the tag directory, which creates
src/generated as its parent.

Test placement

Every new .t.sol is at its subject's mirrored path under test/src/, per #56:
test/src/lib/LibFs.requireTag.t.sol, LibFs.dirForTag.t.sol,
LibFs.pathForTaggedContract.t.sol and LibFs.buildFileForTaggedContract.t.sol,
all subjects of src/lib/LibFs.sol. The suite's own support code stays where its
own kind belongs: test/concrete/LibFsExternal.sol and
test/lib/LibCodeGenSlow.sol are extended in place, neither is moved.

The two structural tests each need a byte range out of a path, so that they can
name each region of it independently rather than rebuilding the path with the
same string.concat the library uses and asserting it equals itself. That is one
helper, so it is LibCodeGenSlow.sliceSlow under that file's uniform …Slow
naming rather than a copy per test contract, and the seven call sites reach it
there.

A third copy of the same helper sits in test/src/lib/LibFs.t.sol, which this
PR does not touch — the untagged structural test on main has its own. Folding
it in is a one-line change to a file outside this diff, so it is reported here
rather than made.

Where the issue's proposed fix was wrong

The issue proposes LibCodeGen.requireContractName(tag) and states "numeric tags
such as 0_1_1 are already identifiers under the existing rule". They are
not.
That rule — since renamed requireIdentifier by #135, and referred to
under its current name from here on — admits a digit only at i > 0, so 0_1_1
reverts, 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_tag is the authority on what a tag is:

/// True if `seg` is a release-tag dir name: three `_`-separated non-empty numeric
/// parts, e.g. `0_1_4` or `12_0_255`.

So requireTag is a separate rule: at least one character, each of them an
ASCII letter, a digit, _ or $. That is the Solidity identifier alphabet with
the 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_255 and the
rolling candidate directory a deploy repo keeps beside the frozen ones, and it
still contains no /, no \, no . and no NUL, so no tag is . or .. and no
tag can add or remove a path segment.

Two further deviations from the issue's sketch:

  • A distinct error type. 0_1_1 is a valid tag and an invalid contract
    name
    — the same string, two verdicts. Reporting both through
    InvalidIdentifier would make a build failure unable to say which argument
    was wrong. InvalidTag is exported from LibFs.sol, where the rule lives.
  • dirForTag as well. The directory is what
    buildFileForTaggedContract hands the write, and the live consumer already
    hand-builds string.concat("src/generated/", deployTag()) for its own
    createDir and readDir. Putting the check on the directory 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.deploy does not
reimplement the header or the address-constant emitter — it calls
LibCodeGen.addressConstantString and LibFs.buildFileForContract from
rain-sol-codegen-0.1.3, and the "old" header in its frozen files is that
version's filePrefix() output verbatim. The citation the issue gives for the
reimplementation, addressConstantString at script/BuildPointers.sol:54-60, does
not point at one: at st0x.deploy@main those lines are the NatSpec of
buildContractPointers, and line 73 is a call into this library.

What that consumer actually cannot do today is upgrade. It reaches the tagged
layout by smuggling the separator through the contract-name argument,
string.concat(deployTag(), "/", name), which only worked because the identifier
rule did not exist at sol-v0.1.3. It does now, so that call reverts on every
current version. pathForTaggedContract is the validated path
it has to move to.

Confinement, which is the point

test/src/lib/LibFs.pathForTaggedContract.t.sol states it over the whole input
domain rather than as a list of escapes. assertConfined checks one path for
every way out at once — it begins with GENERATED_DIR followed by /, it adds
exactly separators(GENERATED_DIR) + 2, no segment is empty, and the only .
anywhere is the appended extension. .. needs a dot, a deeper directory needs a
third separator, and an absolute or doubled-separator path needs an empty
segment, so all of them are excluded together.

testPathForTaggedContractAcceptedArgumentsAreConfined fuzzes each argument as
either 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.
testPathForTaggedContractOneBadByteIsConfined covers the neighbourhood of the
accepted domain, which uniform fuzzing never reaches.

Both of them decide which side of the domain a pair is on by the oracles, not by
whether the call reverted: the accepting branch asserts isTagSlow and
isIdentifierSlow both hold and that the path is confined, and the reverting
branch asserts at least one of them fails. Nothing is assumed away and no pair
reaches the end of the test unasserted. That is the difference between a
confinement property and a test an unconditionally reverting
pathForTaggedContract would satisfy: probed directly, that mutant is KILLED,
with testPathForTaggedContractAcceptedArgumentsAreConfined among the tests
that kill it.

That the difference is real rather than cosmetic is measurable in the matrix
below: before this, testPathForTaggedContractOneBadByteIsConfined had never
appeared as a killer of any of the twenty mutants across three passes — a
confined path satisfied it whether or not the byte should have been admitted,
which is precisely the off by one the test was named for. It now kills M02, M03,
M04, M05 and M07 — every requireTag range-boundary mutant — because a byte
admitted one too early or refused one too late is now a disagreement with the
spelled-out alphabet rather than a path that still looks fine. Raised by
CodeRabbit, confirmed against the code rather than taken on its word, and fixed.

QA

  • Discriminating tests: testPathForTaggedContractProducesTheOrgLayout,
    testPathForTaggedContractAcceptedArgumentsAreConfined,
    testPathForTaggedContractOneBadByteIsConfined, testRequireTagMatchesAlphabet,
    testRequireTagEveryLeadingByte, testRequireTagEveryTrailingByte,
    testRequireTagAcceptsEveryNumericReleaseTag,
    testRequireTagAcceptsLeadingDigitContractNameDoesNot,
    testDirForTagStructure, testPathForTaggedContractStructure,
    testBuildFileForTaggedContractCreatesTheTagDir,
    testBuildFileForTaggedContractLeavesOtherTagsAlone,
    testBuildFileForTaggedContractReplacesDanglingSymlink (+ 43 more, 56 new in
    total) — none of the first 55 can pass on base, because requireTag,
    dirForTag, pathForTaggedContract and buildFileForTaggedContract do not
    exist 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 with main and has its own
    observed red, transcribed under "TDD: the semantic conflict with main".

  • Suite: nix develop -c forge test on d0e1010241 passed, 0 failed, 27
    suites
    , up from 185 passed, 0 failed, 23 suites on main at 1c81613.
    56 new tests in 4 new suites; 0 pre-existing tests changed. CI's own test
    job reports the same 241 / 27.

  • Formatting: nix develop -c forge fmt --check → exit 0.

  • Coverage: nix develop -c forge coverage --no-match-coverage "test|script" on
    the merge commit —

    | src/lib/LibFs.sol        | 100.00% (39/39)   | 100.00% (44/44)   | 100.00% (5/5)   | 100.00% (9/9)   |
    | Total                    | 100.00% (125/125) | 100.00% (150/150) | 100.00% (16/16) | 100.00% (25/25) |
    
  • Fresh tree: rm -rf src/generated then the full suite → same 241 passed. The
    setUp above is what makes that true for a filtered run of the tagged write's
    suite as well.

  • Mutations: 20 mutants over every line this PR adds, run with
    mutation-probe (rainlanguage/adversarial-mutation-test) rather than a
    hand-rolled loop, so a zero-match "mutation" or a suite that never ran cannot
    read as SURVIVED. 20/20 KILLED — 0 survived, 0 no-run, 0 harness errors,
    probe exit code 0, baseline re-verified green (241 passed) before the pass.
    Full matrix below, including the four mutants the merge with main
    invalidated and the probe refused to score.

  • Oracle: LibCodeGenSlow.isTagSlow / tagFromSeedSlow decide tag membership by
    scanning SLOW_TAIL_ALPHABET, which is spelled out character by character,
    against requireTag's range arithmetic — the two sides never share a
    derivation. Paths are asserted positionally against byte literals
    ("src/generated/", "/", ".sol") and against the argument bytes, never by
    re-running the library's own string.concat. File content is asserted against
    the literal SPDX/pragma/header text and address.codehash, not by calling
    filePrefix() / bytecodeHashConstantString(). The tag shapes that must be
    accepted come from rainix-static/src/frozen_snapshots.rs::is_tag and from
    S01-Issuer/st0x.deploy's committed src/generated/ tree, not from this
    library.

  • Category check: The per-tag frozen-snapshot layout the org enforces is unreachable through this library #78 asks for (A) pathForTaggedContract, (B)
    buildFileForTaggedContract beside buildFileForContract, (C) the confinement
    invariant that no tag or name traverses out of GENERATED_DIR. Covered A, B, C.
    The issue's fourth claim — that requireContractName(tag) suffices because
    numeric tags are already identifiers — is refuted rather than implemented; see
    "Where the issue's proposed fix was wrong". Migrating S01-Issuer/st0x.deploy
    is not part of the issue's proposed fix and 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.sol first asserted the org layout
against the API as it stood:

assertEq(LibFs.pathForContract("0_1_1/StoxReceipt"), "src/generated/0_1_1/StoxReceipt.sol");

nix develop -c forge test --match-path 'test/src/lib/LibFs.pathForTaggedContract.t.sol' -vvv:

Ran 1 test for test/src/lib/LibFs.pathForTaggedContract.t.sol:LibFsPathForTaggedContractTest
[FAIL: InvalidContractName("0_1_1/StoxReceipt")] testPathForTaggedContractProducesTheOrgLayout() (gas: 929)
Traces:
  [929] LibFsPathForTaggedContractTest::testPathForTaggedContractProducesTheOrgLayout()
    └─ ← [Revert] InvalidContractName("0_1_1/StoxReceipt")

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 244.54µs
Ran 1 test suite in 15.67ms: 0 tests passed, 1 failed, 0 skipped (1 total tests)

That red is transcribed as it was observed, so it names the error as
InvalidContractName; #135 has since renamed that error InvalidIdentifier
without changing which names it refuses. The red is unchanged by anything that
has landed since: pathForContract on current main still refuses a name
carrying a separator, which is the whole reason this PR exists.

TDD: the semantic conflict with main

main landed LibFs.isPresent while this branch was open. vm.exists answers
for whatever a path resolves to, so it reports a symlink whose target does not
exist as absent; isPresent also asks vm.readLink, which answers for the path
itself. main swapped buildFileForContract's unlink guard to isPresent for
exactly that reason.

No merge marker points at this. buildFileForTaggedContract as this branch first
wrote it guarded its own unlink with vm.exists, so a dangling symlink at the
tagged path was reported absent, the unlink was skipped, and vm.writeFile
followed the link and created the target — while the function's own NatSpec
claimed a symlink there is replaced. A textual merge resolves clean and ships
that. Against the tagged write as this branch first had it:

Ran 1 test for test/src/lib/LibFs.isPresent.t.sol:LibFsIsPresentTest
[FAIL: the write followed the link to its target] testBuildFileForTaggedContractReplacesDanglingSymlink() (gas: 119585)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 4.81ms (4.56ms CPU time)

The tagged write no longer has a guard of its own to get wrong — it is the
dir-carrying buildFileForContract, so it is main's guard by construction,
including the unlink loop #127 later made of it.
testBuildFileForTaggedContractReplacesDanglingSymlink stays, in
test/src/lib/LibFs.isPresent.t.sol beside the untagged case, because what it
asserts is that the guarantee holds at the tagged path, and that is a claim
about the delegation rather than about the guard.

Mutation matrix

Twenty mutants, one per line this PR adds, regenerated after the re-siting onto
#112 — not carried over from the pass that ran against the copies of the
plumbing that used to be here. The pass below was measured on d0e1010, this
PR's head. Baseline verified green (241
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+) failed read from forge's own tally. 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.

== 20/20 killed; survived: 0; no-run: 0; harness errors: 0

The probe names up to five killing tests per mutant; one is transcribed here.
Which tests kill a given mutant is not stable across runs — the fuzz tests draw
different inputs each pass — so only the verdict is stable.

The merge with main invalidated four of these and the probe said so rather
than scoring them.
#135 renamed the identifier rule and gave
buildFileForContract the caller's licence and copyright, so M17's replacement
no longer compiled and M18–M20's targets no longer occurred in the file at all.
Run against the merge commit unchanged, the pass came back
16/20 killed; no-run: 1; harness errors: 3, naming each one — not four silent
survivals, and not four unearned kills off a suite that never ran the mutated
code. The four were rewritten against the merged signatures and the matrix above
is that re-run. This is the reason the mutants are a config the probe validates
rather than a hand-rolled loop.

# Mutation Verdict Killed by
M01 requireTag drops the empty-tag rejection KILLED testBuildFileForTaggedContractRejectsEmptyTag
M02 requireTag uppercase range opens one early (admits @) KILLED testRequireTagEveryLeadingByte
M03 requireTag uppercase range closes one late (admits [) KILLED testRequireTagEveryLeadingByte
M04 requireTag lowercase range opens one early (admits `) KILLED testRequireTagEveryLeadingByte
M05 requireTag lowercase range closes one late (admits {) KILLED testPathForTaggedContractOneBadByteIsConfined
M06 requireTag digit range opens one early (admits /) KILLED testBuildFileForTaggedContractRejectsEveryNonTag
M07 requireTag digit range closes one late (admits :) KILLED testRequireTagEveryLeadingByte
M08 requireTag drops digits from the tag alphabet KILLED testBuildFileForTaggedContractBodyVerbatim
M09 requireTag collapses into the contract-name rule (no leading digit) KILLED testBuildFileForTaggedContractBodyVerbatim
M10 requireTag drops _ from the tag alphabet KILLED testBuildFileForTaggedContractBodyVerbatim
M11 requireTag drops $ from the tag alphabet KILLED testBuildFileForTaggedContractBodyVerbatim
M12 requireTag checks only the first byte of the tag KILLED testBuildFileForTaggedContractRejectsEveryNonTag
M13 dirForTag drops the tag check KILLED testBuildFileForTaggedContractRejectsEmptyTag
M14 dirForTag drops the separator before the tag KILLED testBuildFileForTaggedContractBodyVerbatim
M15 pathForTaggedContract ignores the tag, returns the untagged path KILLED testBuildFileForTaggedContractWritesToPathForTaggedContract
M16 pathForTaggedContract swaps the tag and the contract name KILLED testBuildFileForTaggedContractBodyVerbatim
M17 pathForTaggedContract checks the name before the tag KILLED testPathForTaggedContractRejectsTheTagFirst
M18 buildFileForTaggedContract writes to the untagged directory KILLED testBuildFileForTaggedContractBodyVerbatim
M19 buildFileForTaggedContract swaps the tag and the contract name KILLED testBuildFileForTaggedContractBodyVerbatim
M20 buildFileForTaggedContract builds the directory itself, skipping the tag check KILLED testBuildFileForTaggedContractRejectsEmptyTag

What is no longer in this matrix, and why. The previous pass on this branch
carried mutants for the contract-name check, the / and .sol interpolation,
vm.createDir(dir, true), the isPresent unlink guard and the vm.writeFile
target. Those lines are not this PR's any more — they are #112's, reached
through the delegation, and main's own
test/src/lib/LibFs.buildFileForContract.t.sol is what mutation-covers them.
What replaces them here is the pair that actually tests the delegation: M15 and
M18 are "use GENERATED_DIR where dirForTag(tag) belongs", so a tagged call
that silently landed on the untagged path is caught, and M20 is "build the
directory string inline instead of through dirForTag", so dropping the tag
check on the way to the write is caught.

M17 is killed by exactly one test. It inserts
LibCodeGen.requireIdentifier(contractName) ahead of the delegation, which
changes nothing except which error a call with both arguments wrong reports.
testPathForTaggedContractRejectsTheTagFirst is the only test in all 241 that
distinguishes it, which is the whole reason that test exists.

Consumer migration — reported, not done

No consumer is touched by this PR. S01-Issuer/st0x.deploy is a live consumer of
the per-tag layout — it is the one this was read against, at main — and
migrating it is not a call-site swap. It is not established to be the only
one: every gh search code query tried for the layout returned zero results, so
the 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.sol imports rain-sol-codegen-0.1.3 and calls
    LibFs.buildFileForContract(vm, deployed, string.concat(deployTag(), "/", name), …)
    and LibCodeGen.addressConstantString(…). The pointer files are library
    output, not hand-rolled. The header in the frozen files
    (// THIS FILE IS AUTOGENERATED BY ./script/BuildPointers.sol plus the
    circular-dependency paragraph) is filePrefix() at sol-v0.1.3 verbatim.
  • What is hand-rolled there is the deploy-lib generation — genV4 /
    genCurrent emitting src/generated/LibProdDeployV4.sol and
    LibProdDeployCurrent.sol line by line with vm.writeLine under their own
    header. 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:

  1. The extension changes and that is the blocker. pathForContract at
    0.1.3 appended .pointers.sol; it appends .sol today, and
    pathForTaggedContract inherits that. All 22 committed pointer files would be
    renamed. The 12 under candidate/ rename freely. The 10 under 0_1_1/ are
    frozen, and frozen_snapshots::check diffs with --no-renames precisely so a
    rename surfaces as D + A and the D is flagged. So the frozen snapshot
    cannot be renamed without a human ruling on the append-only rule.
  2. Regenerating the frozen snapshot is blocked for the same reason, and
    independently: the current filePrefix() differs from 0.1.3's, so any
    regeneration 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.
  3. Every import … from "./<tag>/<Name>.pointers.sol" line genV4 emits, and
    the pointerExists probe, carry the extension and move with it.
  4. Only then is the call-site swap itself trivial:
    buildFileForContract(vm, addr, string.concat(tag, "/", name), body)
    buildFileForTaggedContract(vm, addr, tag, name, licence, copyright, body),
    and the explicit
    vm.createDir(string.concat("src/generated/", deployTag()), true) becomes
    redundant. The licence and copyright are new arguments that Parameterise the generated file's licence and copyright #135 added to the
    untagged write, so that consumer has to name its own either way.

candidate needs no special handling: it is letters only, so requireTag
accepts it.

The honest summary is that this PR makes the layout reachable, and a migration
of st0x.deploy is a separate piece of work whose first question — whether a
frozen snapshot may be renamed — is a human ruling, not a code change.

Also found, not touched

  • testRequireTagErrorCarriesTheTag may be the test main deleted. main
    removed testRequireContractNameErrorCarriesTheName from what is now
    test/src/lib/LibCodeGen.requireIdentifier.t.sol on the reasoning that
    assertRejected already asserts the whole error — selector and argument — so
    every rejection in the file pins that claim and a separate test for it is a
    second surface for one fact. LibFs.requireTag.t.sol has the same shape: its
    assertRejected pins InvalidTag.selector, tag, and
    testRequireTagErrorCarriesTheTag re-states it for one input. 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.
  • The unlink loop takes a live symlink's target with it. buildFileForContract
    on main unlinks the generated path until it holds nothing, and vm.removeFile
    resolves the path before it acts, so on a live symlink the first pass deletes
    what the link points at. Unlink the generated path until it holds nothing, so a live symlink is replaced too #127 landed that deliberately and says so in its
    NatSpec, and main's own testBuildFileForContractReplacesLiveSymlink asserts
    the target does not survive — but it means a pre-existing symlink at a
    generated path can take a file elsewhere under fs_permissions with it.
    Raised by CodeRabbit against this PR's delegation line; it is main's
    behaviour, identical for the untagged write, and this PR adds no unlink of its
    own. Changing it is a ruling on Unlink the generated path until it holds nothing, so a live symlink is replaced too #127's landed design that moves both writes at
    once, so it is reported rather than changed here, and the thread is left open.
  • test/src/lib/LibFs.buildFileForContract.t.sol on main writes a STALE
    sentinel that is not Solidity to src/generated/<name>.sol and removes it on
    the way out, 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 a parse error. That is the same defect the two sentinels in this
    PR's own tests had — both are formatted Solidity comments here — and it is what
    made the first mutation pass on this branch unscorable. It is a sibling's file,
    so it is reported here rather than changed.

thedavidmeister and others added 3 commits August 16, 2026 18:43
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>
@thedavidmeister thedavidmeister self-assigned this Aug 16, 2026
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

LibFs adds tag validation, tag-specific directory and contract path construction, and tagged contract file generation. Tests cover path confinement, invalid inputs, file isolation, overwriting, idempotence, and dangling symlink replacement.

Changes

Tagged filesystem support

Layer / File(s) Summary
Tag validation and tagged paths
src/lib/LibFs.sol, test/lib/LibCodeGenSlow.sol, test/src/lib/LibFs.requireTag.t.sol, test/src/lib/LibFs.dirForTag.t.sol, test/src/lib/LibFs.pathForTaggedContract.t.sol
LibFs validates nonempty tags containing ASCII letters, digits, _, or $. It constructs tagged directories and contract paths beneath GENERATED_DIR. Tests cover valid tags, invalid bytes, traversal, layout, confinement, and validation order.
Tagged contract file generation
src/lib/LibFs.sol, test/concrete/LibFsExternal.sol, test/src/lib/LibFs.buildFileForTaggedContract.t.sol, test/src/lib/LibFs.isPresent.t.sol
buildFileForTaggedContract delegates tagged writes to existing directory-based file generation. Wrappers expose the new functions. Tests cover generated contents, isolation, replacement, overwriting, idempotence, and dangling symlink handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🟠 High · up to 11671

The tagged write path can follow a pre-existing live symlink and delete its target before generating the snapshot, creating a concrete unintended-data-loss risk; the related property tests also accept reverts for valid inputs. Merge should be blocked until cleanup is symlink-safe and the tests distinguish invalid inputs from valid-input failures.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant LibFs
  participant Filesystem
  Caller->>LibFs: buildFileForTaggedContract(vm, instance, tag, contractName, body)
  LibFs->>LibFs: requireTag(tag)
  LibFs->>LibFs: pathForTaggedContract(tag, contractName)
  LibFs->>Filesystem: create or replace tagged contract file
  Filesystem-->>Caller: generated file at tagged path
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the tagged APIs, path confinement, validation, and file-generation support required by issue #78.
Out of Scope Changes check ✅ Passed The changes stay within tagged LibFs support, related helpers, and tests; consumer migration is explicitly outside this PR's scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding LibFs support for the per-tag frozen-snapshot layout.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 2026-08-16-issue-78

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

thedavidmeister and others added 5 commits August 17, 2026 04:52
`pathForTaggedContract` is `pathForContractIn(dirForTag(tag), contractName)`
and `buildFileForTaggedContract` is the five argument `buildFileForContract`
applied to `dirForTag(tag)`, so the name check, the separator, the extension,
the directory creation, the unlink guard and the write exist once. Argument
evaluation runs `dirForTag` before the call, so the tag is still checked before
the name and before any cheatcode.

`LibFsBuildFileForTaggedContractTest` creates `GENERATED_DIR` in `setUp`:
`testBuildFileForTaggedContractLeavesTheUntaggedFileAlone` writes its sentinel
there directly, and `src/generated/` holds no committed file, so on a fresh
clone that write has no parent directory unless another test in the contract
happened to run first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test/src/lib/LibFs.requireTag.t.sol`, `LibFs.dirForTag.t.sol`,
`LibFs.pathForTaggedContract.t.sol` and `LibFs.buildFileForTaggedContract.t.sol`
do not exist on `main`, so #126's rewrite had nothing to rename in them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@test/src/lib/LibFs.pathForTaggedContract.t.sol`:
- Around line 52-57: Move the duplicated slice helper into LibCodeGenSlow beside
indexOfSlow, preserving its byte-copy behavior. In
test/src/lib/LibFs.pathForTaggedContract.t.sol lines 52-57 and
test/src/lib/LibFs.dirForTag.t.sol lines 44-49, remove the local definitions and
update all call sites to use LibCodeGenSlow.slice(...); both files already
import the shared library.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 65c4658f-1549-4dff-9cfd-1daaadcc5c27

📥 Commits

Reviewing files that changed from the base of the PR and between 89cb0a2 and 6132d3d.

📒 Files selected for processing (8)
  • src/lib/LibFs.sol
  • test/concrete/LibFsExternal.sol
  • test/lib/LibCodeGenSlow.sol
  • test/src/lib/LibFs.buildFileForTaggedContract.t.sol
  • test/src/lib/LibFs.dirForTag.t.sol
  • test/src/lib/LibFs.isPresent.t.sol
  • test/src/lib/LibFs.pathForTaggedContract.t.sol
  • test/src/lib/LibFs.requireTag.t.sol

Included review availability: Your plan includes up to 1 review per rolling hour; 0 remain after this review.

Comment thread test/src/lib/LibFs.pathForTaggedContract.t.sol Outdated
thedavidmeister and others added 4 commits August 17, 2026 05:27
The two tagged-path test contracts carried a byte-identical `slice` each.
It is the same slow restatement of a byte range that the rest of
`LibCodeGenSlow` holds, so it lives there under that file's naming, and
the seven call sites reach it there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`main` gave `buildFileForContract` the caller's licence and copyright and
renamed the identifier rule, neither of which a textual merge reaches:
the tagged overload delegates to a signature that no longer exists. It
carries both through to the delegate, and the paragraph restating the
single unlink is replaced by what the loop main landed actually does.

The two sides each added a symlink test to `LibFsIsPresentTest`; both
stay, one for the tagged path and one for a live link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@src/lib/LibFs.sol`:
- Around line 316-325: Update the cleanup path used by
buildFileForTaggedContract and buildFileForContract so pre-existing symlinks are
never followed or removed through their targets. Unlink only the symlink
directory entry; if the filesystem API cannot do this safely, reject the symlink
and leave both link and target unchanged. Update the live-symlink test to assert
that the target remains unchanged.

In `@test/src/lib/LibFs.pathForTaggedContract.t.sol`:
- Around line 179-183: The catch blocks around pathForTaggedContract must fail
when both inputs are valid, rather than discarding every revert. Update each
catch to assert that the tag or contractName is invalid using
LibCodeGenSlow.isTagSlow and LibCodeGenSlow.isIdentifierSlow, while retaining
the existing oracle assertions and assertConfined(path) in successful branches.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 55d7d04b-3a63-4466-b7cf-6248acd70f41

📥 Commits

Reviewing files that changed from the base of the PR and between 6132d3d and 116717f.

📒 Files selected for processing (8)
  • src/lib/LibFs.sol
  • test/concrete/LibFsExternal.sol
  • test/lib/LibCodeGenSlow.sol
  • test/src/lib/LibFs.buildFileForTaggedContract.t.sol
  • test/src/lib/LibFs.dirForTag.t.sol
  • test/src/lib/LibFs.isPresent.t.sol
  • test/src/lib/LibFs.pathForTaggedContract.t.sol
  • test/src/lib/LibFs.requireTag.t.sol

Included review availability: Your plan includes up to 1 review per rolling hour; 0 remain after this review.

Comment thread src/lib/LibFs.sol
Comment thread test/src/lib/LibFs.pathForTaggedContract.t.sol Outdated
Both confinement tests swallowed every revert, so which side of the
domain a pair was on was decided by the library's own answer. A pair
both oracles accept now has to produce a path, and the neighbourhood
test asserts the agreement it was already named for: a range that opens
or closes one byte too far disagrees with the spelled-out alphabet
instead of handing back a path that still happens to be confined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister
thedavidmeister merged commit 7266130 into main Aug 17, 2026
4 checks passed
thedavidmeister added a commit that referenced this pull request Aug 17, 2026
…gainst

#137 landed `buildFileForTaggedContract`, which writes into exactly the per
release snapshot directories this section called untouched. They are not:
that write enters the same shared body, so it reads the directory it writes
into and checks it against its own contents. Only a generation into
`src/generated/` itself leaves them unread, and it never refuses one of them
because a tag carries no `.`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The per-tag frozen-snapshot layout the org enforces is unreachable through this library

1 participant