Skip to content

Seed suiteNames's named return before the loop - #103

Closed
thedavidmeister wants to merge 1 commit into
mainfrom
2026-08-15-issue-72-suitenames-named-return
Closed

Seed suiteNames's named return before the loop#103
thedavidmeister wants to merge 1 commit into
mainfrom
2026-08-15-issue-72-suitenames-named-return

Conversation

@thedavidmeister

Copy link
Copy Markdown
Contributor

Closes#72 (audit finding CQ4-02, dimension 4, severity LOW).

The finding

suiteNames() declared the named return names and assigned it only inside for (uint256 i = 0; i < suites.length; i++). Judged from this function's body alone, suites.length == 0 reaches the closing brace with names never assigned and returns the empty string.

What actually makes that path unreachable is checkedCandidateSuites() reverting NoDeployCandidates — two functions away, reached via allSuites(). So the return is total, but only via a guard this reader cannot see. That is the org rule's case 1: assigned only inside a loop, where a guard elsewhere is what makes every path assign it.

It matters rather than being academic because names is the validSuites field of UnknownDeploymentSuite — the only thing a mistyped DEPLOYMENT_SUITE tells a deployer. If the guard in checkedCandidateSuites were ever narrowed, this reports "no valid suites" as an empty string instead of failing.

The change

function suiteNames() internalpurereturns (stringmemorynames) {
DeploySuite[] memory suites =allSuites();
+// Seeded here so every path out of this function assigns `names`,+// provable from this body alone. `allSuites` never yields an empty set,+// but that is a fact about another function.+ names ="";
for (uint256 i =0; i < suites.length; i++) {
names = i ==0? suites[i].suite : string.concat(names, ", ", suites[i].suite);
}
}

Four lines, one of them code.

The loop and its i == 0 seed are deliberately untouched, and mutant M4 below is the evidence they must be: with names seeded empty and the ternary dropped, the list comes back as ", address-registry-0-0-1, second-address, ...". The ternary is what keeps the first element unprefixed, so folding it into the seed is a behaviour change, not a simplification.

Why not the fix proposed in the issue body

The issue's "After" block also added if (suites.length == 0) { return names; }. The issue's own collapsed verification block flags and rejects that, and I agree on reading the source:

  • It is unreachable dead code — allSuites() cannot return an empty array.
  • It writes into the source the exact thing checkedCandidateSuites' NatSpec forbids: "a reader that answers from an empty declaration is a reader through which the whole registry can be empty and green."
  • testNoCandidateReverts asserts externalSuiteNames()reverts on an empty declaration. An early return is a second, contradictory spelling of that rule sitting in the source.

So this PR implements the minimal remedy the verification block prescribes — seed only, no early return.

QA

  • Discriminating tests: none added, and adding one is not possible here — stating that plainly rather than inventing a test. This is a local-provability fix, not a behaviour fix: names = ""; is only observable on suites.length == 0, which checkedCandidateSuites makes unreachable by reverting NoDeployCandidates. A test that reached the line would first have to disable the guard the repo exists to enforce. The three existing tests that already pin this function are audited below rather than duplicated, per "an existing test that already kills mutants is left as-is": testSuiteNamesIsTheRegistry, testEmptySuiteIsUnknown, testUnknownSuiteNamesEveryValidSuite (all in test/src/abstract/RainDeploySuitesBase.t.sol), plus testNoCandidateReverts pinning that the empty path reverts rather than answers.
  • Mutations applied: harness proven live first — unmutated baseline ran 10 tests, 10 passed, 0 failed, with testSuiteNamesIsTheRegistry, testNoCandidateReverts and testEmptySuiteIsUnknown each named [PASS] in the output. Each mutant was greped back out of the file after sed to confirm it actually applied, so a no-op edit cannot fake a survival. All against forge test --match-path test/src/abstract/RainDeploySuitesBase.t.sol:
    • M1 — delete names = ""; (the line this PR adds) → SURVIVED (10 passed, 0 failed). Expected and reported as such rather than papered over: the line's whole purpose is to make an unreachable path assign, so no test can distinguish its presence. This is the known outcome for this finding class, not a coverage gap.
    • M2i == 0i == 1KILLED, 3 failed. testSuiteNamesIsTheRegistry (second-address, address-registry-candidate, second-address-candidate != address-registry-0-0-1, second-address, ...), testEmptySuiteIsUnknown, testUnknownSuiteNamesEveryValidSuite. Run with the new seed present, which is what proves the seed did not weaken the ternary's coverage.
    • M3 — separator ", "","KILLED, 3 failed, same three tests.
    • M4 — drop the ternary, always string.concat(names, ", ", suites[i].suite)KILLED, 3 failed, same three tests, output ", address-registry-0-0-1, ..." with the leading comma. This is the mutant that proves the i == 0 branch cannot be collapsed into the seed.
  • Oracle: the expected key list is independent of suiteNames' implementation — it is the registry order declared in the test fixture's own releasedSuites()/candidateSuites() (address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate), written out longhand as a literal in the test rather than recomputed by concatenating in the assertion. For the empty case the oracle is checkedCandidateSuites' NatSpec, which states that every reader must refuse an empty declaration — that is what rules out the early return the issue body proposed.
  • Category check: the issue asks for exactly one thing — make the assignment total within this function's own body. Covered. The issue body's proposed diff additionally contained an early return; that is deliberately not implemented, because the issue's own verification block identifies it as unreachable dead code contradicting checkedCandidateSuites' NatSpec and testNoCandidateReverts, and prescribes the seed-only remedy instead. No adjacent scope taken: allSuites, checkedCandidateSuites and suiteByName are untouched.

Full verification run

  • nix develop -c forge build — clean, exit 0.
  • nix develop -c forge test with all five RPC endpoints set: 215 passed, 0 failed, 0 skipped across 17 suites.
  • nix develop -c forge fmt --check — clean, exit 0.

(Without RPCs, 47 fork tests fail on vm.createSelectFork: environment variable ..._RPC_URL not found. That is the requirement documented in CLAUDE.md, not this change — they pass once the endpoints are set, which is the 215/215 above.)

🤖 Generated with Claude Code

`suiteNames` declared `names` and assigned it only inside
`for (uint256 i = 0; i < suites.length; i++)`. Judged from this
function's body alone, `suites.length == 0` reaches the closing brace
with `names` unassigned and returns the empty string. What makes that
path unreachable is `checkedCandidateSuites` reverting
`NoDeployCandidates`, two functions away through `allSuites` — so the
return is total only via a guard this reader cannot see.
Seeding `names = ""` before the loop makes the assignment provable from
this body. The loop and its `i == 0` seed are untouched: with an empty
seed, `string.concat("", ", ", suites[0].suite)` would prefix the list
with a comma, so the ternary is still load-bearing.
No early return on the empty set. `checkedCandidateSuites`' NatSpec and
`testNoCandidateReverts` both require every reader to refuse an empty
declaration rather than answer from one, and an
`if (suites.length == 0) { return names; }` would write that answer into
the source as unreachable code.
Behaviour is unchanged on every reachable input, so the existing
`testSuiteNamesIsTheRegistry` and `testNoCandidateReverts` remain the
coverage.
Closes#72
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeisterthedavidmeister self-assigned this Aug 15, 2026
@coderabbitai

Copy link
Copy Markdown

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 96d1ec76-5e8f-4302-b94d-60d08bcd887b

📥 Commits

Reviewing files that changed from the base of the PR and between 86f8d96 and bf26f73.

📒 Files selected for processing (1)
  • src/abstract/RainDeploySuitesBase.sol

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 added a commit that referenced this pull request Aug 16, 2026
…atSpec
`/// @return networks` sat above `returns (string[] memory)` — unnamed — so
`networks` was never a return-parameter name, solc had nothing to check it
against, and the word was folded into the description text. Two forms close
that gap: name the return, or drop the name from the tag.
Drop it. `zoltuAddress` (src/lib/LibRainDeploy.sol:210-211) already documents
its unnamed return as `/// @return The address the creation code deploys to.`,
and issue 50's own text calls that form correct. This repo does not name return
values: an unnamed return keeps solc's "unnamed return variable can remain
unassigned" diagnostic, which naming it gives up — see
#103, where the remedy for a
named return's silent default is a line no test can kill.
The signature, the local `string[] memory networks` and the explicit
`return networks;` are unchanged. This reverts the code edit made earlier on
this branch and fixes the docstring instead; the net change against `main` is
one comment line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister

Copy link
Copy Markdown
ContributorAuthor

Closing: the ruling is that this repo does not name return values, so the fix is to remove the name rather than to seed the default it silently supplies.

This PR is also its own best argument for that. Its mutation run reports that deleting the line it adds SURVIVED the whole suite — the line exists to make an unreachable path assign, so no test can distinguish its presence. The remedy for a named-return hole is unkillable by any test, because the compiler was the only mechanism that could ever have caught the class, and naming the return is what turned it off.

Superseded by the sweep issue, which also covers #72 and the generator that emits named returns into Lib*Released.sol.

Sign up for freeto 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.

suiteNames's named return is assigned only inside a loop, made total by a guard in another function

1 participant

@thedavidmeister