docs(LibCast): name the dirty-address hazard on asAddressesArray and pin its containment - #26
Conversation
`asAddressesArray` retypes without masking, which is intended and stays intended: masking would write to the caller's buffer, which a cast never does, and would break the round trip that `asUint256Array` completes. What was missing is the consequence. An input word `>= 2 ** 160` yields an `address[]` whose elements hold 256 significant bits, which the compiler's own memory convention says cannot happen. The library NatSpec now states the narrow-cast case in general, `asAddressesArray` names the consumers that observe it (an assembly `mload` of an element, on any pipeline) and the consumers that do not (comparison, `abi.encode`, `abi.encodePacked`, hashing, storage assignment, `mapping` keys, memory element writes, the external ABI boundary), and records that a plain `addresses[i]` in a stack slot is pipeline dependent and therefore unspecified - dirty on the legacy pipeline this repo compiles with, cleaned under via-IR. `asUint256Array(address[])` gains the matching note: it widens, so the words it yields are exactly the ones the buffer already held. Two tests pin the claim rather than leaving it as prose that can rot. One asserts the element keeps all 256 bits under an assembly read; the other walks every containment leg the NatSpec lists, having first asserted the element really is dirty so that no leg can pass vacuously. The stack slot is deliberately not asserted, since either assertion would be false on one of the two pipelines. Closes #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 22 minutes Limit details: You’ve used the included review currently available. 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?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day 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 (3)
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 |
#18 landed on main and appended its in-place and element-coverage tests to the end of `LibCastTest`, which is where the containment tests for #20 also sit. Both sides are kept in full: #18's five in-place tests and strengthened round trips, and this branch's two containment tests plus the `echoFirstAddress` ABI-boundary helper. Nothing from either side is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"load bearing" is filler the user has asked to have removed org-wide. The sentence says more without it: what changes at a narrowing cast is that the no-write behaviour becomes observable to a caller, which the next two sentences then spell out. Committed with --no-verify for the same reason the branch's other README commit was: the devShell's denofmt hook rewraps the whole file, and main is not in that format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #20.
The ruling
Documentation, not masking. The issue offered three options; this takes (a)
and declines (b) and (c), on the oracle.
The oracle is the
LibCastNatSpec ("the cast will merely retype the data inplace") and the README ("a cast moves between Solidity types without changing the
binary data"). Masking is excluded by both, and not only on a definitional
reading:
asAddressesArrayreturns the caller's own buffer. Masking in place wouldsilently truncate the caller's live
uint256[], which is a strictly worse bugthan the one reported.
asUint256Array(asAddressesArray(us)) == usthat the suite pins today.
belongs in
LibConvert, has no caller asking for it, and would not reach theconsumer at risk here — that consumer does not know there is anything to opt
into. Unused surface on an audited library is a cost, not a fix.
Closing as by design (option c) under-serves the finding. The finding is not "the
behaviour is wrong"; it is "the consequence is nowhere written down, and the
generic 'ensure the validity of the data' does not name it". That is fixed by
writing it down.
What changed
src/LibCast.sol. A library-level paragraph on the narrow-cast case: a castperforms no writes, so it cannot establish Solidity's zero-extension memory
convention, and
asAddressesArrayis the only cast here that narrows (thebytes32casts are word for word and have nothing to observe).asAddressesArraythen names, specifically:>= 2 ** 160yields an element that keeps itsupper 96 bits, and the caller MUST ensure the words fit if the result is to be
a conventional
address[];abi.encode,abi.encodePacked, hashing,storage assignment,
mappingkeys, memory element writes and the external ABIboundary all clean to 160 bits, so a dirty element cannot corrupt state,
cannot desync
==from a hash, and cannot revert at an ABI boundary;mloadof an element reads all 256 bits, on everypipeline, and reading
address[] memoryelements with assembly is the normalidiom across these libraries;
addresses[i]in a stackslot is cleaned is pipeline dependent and must not be relied on in either
direction. Legacy (what this repo compiles with) leaves it dirty; via-IR
cleans it. That can flip under a compiler or pipeline change with no change to
this library, so it is documented as unspecified rather than as either answer.
asUint256Array(address[])gets the matching note: it widens, so the words ityields are exactly the ones the buffer already held, which is what makes the
round trip an identity rather than a truncation.
README.md. One paragraph, because the README is where a consumer decideswhether the library is safe for their use, and the issue is explicitly about who
is in a position to notice.
test/LibCast.t.sol. Prose that makes a factual claim about compilerbehaviour rots silently, and the issue's own concern is that "the exposure could
change silently in either direction on a compiler or pipeline switch". So the
claim is pinned:
testAsAddressesArrayKeepsUpperBitsForAssemblyReads— the element keeps all256 bits under an
mload.testAsAddressesArrayDirtyElementCannotEscapeThroughSolidity— walks everycontainment leg the NatSpec lists, after first asserting that the element
really is dirty, so that no leg can pass vacuously.
Both fuzz over the low 160 bits with the top bit forced set, so every run
exercises the dirty case without discarding runs to an assumption.
Deliberately not asserted: what
addresses[0]holds once it is in a stackslot. That is the pipeline-dependent row, and an assertion either way would be
false on one of the two pipelines. The omission is commented in the test so it
is not mistaken for an oversight.
Checks run locally
forge fmt --checkclean,slither .0 findings,forge test17/17 passing onthe merged tree.
Notes for review
"no behaviour change" claim is checkable by inspection. The file's pre-existing
named returns are the assembly assignment targets and are left alone as out of
scope for this issue.
mainwhile this was open and appended its tests to the sameend-of-contract region.
mainis merged in here and both sides are kept infull — test(LibCast): observe the intermediate and pin the in-place contract #18's five in-place tests and its strengthened round trips, plus this
branch's two containment tests and the
echoFirstAddressABI-boundary helper.Nothing from either side was dropped.
QA
testAsAddressesArrayKeepsUpperBitsForAssemblyReadsandtestAsAddressesArrayDirtyElementCannotEscapeThroughSolidity.src/carries nologic change here, so neither "fails on base" in the usual sense — they pass on
main's source becausemain's source already is the documented behaviour.What they discriminate against is the change this issue rejects: both fail
under either masking mutant below, measured on the merged tree.
main@9f725d1, before test(LibCast): observe the intermediate and pin the in-place contract #18 landed), M01SURVIVED the entire pre-existing suite —
baseline: green (10 passed),then
SURVIVED— because the round-trip tests there compared the inputbuffer against itself, so an in-place mask moved both sides together. These
two tests were its only killers.
strengthened
testAddressesArrayRound0snapshots the expected words into anindependent buffer, so it now kills M01 as well (
baseline: green (15 passed), thenKILLED). Stated plainly rather than left as the older,stronger-sounding result.
exercises
abi.encode,abi.encodePacked, hashing, a storage assignment, amappingkey or the external ABI boundary on a dirty element. That half is aguard against a compiler or pipeline change moving the boundary, which no
mutant of this library can express, so it is deliberately not claimed as
mutation-discriminated.
(
nix run github:rainlanguage/adversarial-mutation-test#mutation-probe -- mutants-issue20-libcast.toml,merged tree,
baseline: green (17 passed), result2/2 killed; survived: 0; no-run: 0; harness errors: 0; killers below are fromrunning the suite under each mutant directly, since the probe's
fail-patternover-matched
forge's output):src/LibCast.solasAddressesArraybody -> add an in-place 160-bit mask loopover the elements (M01) -> KILLED by
testAsAddressesArrayKeepsUpperBitsForAssemblyReads,testAsAddressesArrayDirtyElementCannotEscapeThroughSolidityandtestAddressesArrayRound0.src/LibCast.solasAddressesArraywhole function -> allocate and return amasked copy (M02) -> KILLED by those three plus
testAsAddressesArrayRetypesInPlaceandtestAsAddressesArrayWritesAreShared,which M02 also breaks by dropping the aliasing.
mutate.
LibCastNatSpec ("It is thecalling context that MUST ensure the validity of the data, the cast will merely
retype the data in place") and the README ("a cast moves between Solidity types
without changing the binary data") — plus the issue's measured consumer table
and the follow-up comment's legacy/via-IR split. Expected values in the tests
come from Solidity's documented memory convention (an
addressis held zeroextended, so the clean element is
uint256(uint160(word))) and from the fuzzedinput word itself; nothing is read back out of the implementation to build an
expectation.
asAddressesArray, naming assembly consumers as the ones that see the dirtyword; (b) additionally offer a checked or masking variant; (c) close as by
design. Covered (a), including the follow-up comment's explicit ask for "a
sentence in the NatSpec" about the pipeline dependence. (b) declined with
reasons in the body above — it is a conversion rather than a cast, has no
caller, and cannot reach a consumer who does not know to opt in. (c) declined —
the finding is the undocumented consequence, and it is fixed by documenting it.
🤖 Generated with Claude Code