Uh oh!
There was an error while loading. Please reload this page.
Check the hex charset in LibHexString.bytesToHex - #108
Conversation
The docs on `UnexpectedHexString` and on `bytesToHex` both state the `Vm` must return "0x" followed by two hexadecimal characters per input byte, but only the length and the prefix were checked, so `0xZZZZ` reached generated source as `hex"ZZZZ"`. Every character behind the prefix is now checked against the lower case hexadecimal charset, and the fuzz oracle derives conformance from the same rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Warning Review limit reached
Next review available in:5 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 (2)
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 |
`testBytesToHexStripsOrRevertsForEveryVmOutput` said its constructed payload is "arbitrary in everything except the length and prefix the library actually checks". This PR adds a charset check to `bytesToHex`, so the library now checks the shape rather than the length and prefix alone, and an arbitrary `filler` conforms only rarely: measured at 2048 runs against seeds 1, 2 and 3 the accepted half is reached 57, 56 and 52 times here against 1021, 991 and 1036 on `main`. The construction itself is untouched. Mapping `filler` into `0`-`9a`-`f` to restore the even split belongs to the PR that built the accept arm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
Closes#68
The call: tighten the code, not the docs
#68 offers two routes — check the hexadecimal charset the docs promise, or narrow
the two docstrings to the length-and-prefix check the code actually performs.
Tightened the code.
The charset is not decoration on this function. Its entire job is to produce the
payload of a
hex"..."literal in generated Solidity, and the two failure modesthe guard already covered are the same class as the one it did not:
Vmreturns2n+20x0x, payload not hexhex"ZZZZ"which does not compile and blames the generated file, not theVmThe suite already asserted the charset as a property of the output
(
testBytesToHexCharset, with the rationale "Anything else in the stringterminates or corrupts the
hex"..."literal it is spliced into"), and bothdocstrings already claimed it. The implementation was the only place that did
not, and the fuzz oracle encoded the gap rather than the definition it says it
derives from.
Ledger
Tighten (taken)
LibHexString.bytesToHex(a bounds-checked scan plus oneclause on the existing strip gate), 9 tests, 7 lines in the fuzz oracle.
character —
testBytesToHexLongData(300 bytes in, 600 characters out) moves44,931 → 169,939 gas,
testBytesToHexKnown4,904 → 7,686.bytesToHexcallsvm.toString, so it only ever executes against the cheatcode address inside aforge test or script: that gas is wall-clock on a codegen run, never gas anyone
pays. The file already states this tradeoff explicitly for its revert path
("a build-time function where gas is not a consideration"), which is also why
the scan is written in bounds-checked Solidity rather than hand-rolled Yul.
clause.
UnexpectedHexStringkeeps its signature and no caller, storage layoutor interface depends on the new behaviour, so removal is local to one file plus
its test.
Vmreturning the right length and prefix with anon-lower-case-hex payload now reverts instead of returning. Foundry's own
Vmnever does this, and the only in-tree caller is
LibCodeGen.bytesConstantString, which is unaffected.Narrow the docs (declined)
Vmreturn canpoison generated source and waves the third through; leaves
testBytesToHexCharset's stated rationale enforced on the output but not onthe input it is derived from; and leaves the fuzz property named in LibHexString.bytesToHex documents a hex-charset guarantee it does not check, and the fuzz oracle encodes the same gap #68 stating
it derives conformance from the definition of
toString(bytes)while encodingsomething weaker. Nothing about that gets cheaper with time.
anyone reads the two docstrings against the code.
The docstrings are still touched, because the old wording overclaimed in a second
way the issue does not raise: it said the
Vmmust "return the string thattoString(bytes)is defined to return". No shape check can establish that — aVmreturning0xdeadbeefforhex"aabb"is the right length, prefix andcharset and is still the wrong string, and telling that apart means redoing the
conversion the
Vmwas called for. The docs now say the shape is what ischecked, and say so out loud.
Where the issue's proposed fix was changed
memory-safeassembly block, readingbyte(0, mload(add(add(hexString, 0x20), i))). That is correct but it does afull word load per character, and for a payload whose length is a multiple of
32 the final loads run past the string's own allocation into memory after the
free memory pointer. Solidity's bounds-checked indexing in the function body
is the same check with none of that, and gas is not a consideration here.
check, and its result gates the strip via
and(hexCharset, ...). Ordermatters: the strip mutates the string in place, so a charset check after it
would put the stripped string into
UnexpectedHexString, contradicting@param hexString The string the Vm returned.0-9anda-f, which is right;this PR pins the four range boundaries in both directions (rejects
/,:,a backtick and
g; accepts0,9,a,f) rather than only spot-checkingone non-hex character.
The interaction with #102, measured
#102 (issue #59) has landed. It renamed the fuzz property this PR extends to
testBytesToHexStripsOrRevertsForEveryVmOutput(bytes,string,bool)and gave it aCONSTRUCTED accept arm, because an unconstructed
Vmreturn conforms 0 times in2048 runs. This PR adds a charset clause to the
conformspredicate inside thatsame function and changes nothing else in it.
An earlier revision of this description predicted, without measuring, that "with
this PR merged, an arbitrary-byte payload fails the charset check almost always, so
#102's accept arm collapses back toward the 0 reaches per 2048 it exists to fix."
Measured, it does not. A per-run counter on
conforms,2048 runs, the same three seeds, on
main(89cb0a2) and on this head:mainmain/ hereThe
maincolumn reproduces #102's own figures exactly — #102 recorded1021/991/1036/984per 2048 for seeds 1-4 — so this counter is measuring the samething #102 measured, not a different quantity. The accept arm survives because two constructions still pass
the charset: an empty
filler, for which #102's loop fills the payload with theliteral
"a", and afillerwhose used bytes all land in0-9/a-f.So the property still exercises both halves, but the accepted half is reached
about 2.6% of runs rather than about 50%. Mapping
fillerinto0-9/a-fwould restore the even split. That belongs to #102's diff, not this one, and is
not applied here.
Two smaller findings from the same measurement:
adds put conforming string literals such as
"0x0123456789abcdef"into thecompiled artifacts, and forge seeds the fuzz dictionary from those, so a raw
fillernow conforms occasionally (2 of 2048 at seed 3).falsifies it: it said the accepted string is "arbitrary in everything except
the length and prefix the library actually checks", and the library now checks
the charset too. The construction itself is untouched.
QA
testBytesToHexRevertsOnNonHexVmOutput,testBytesToHexRevertsOnUpperCaseVmOutput,testBytesToHexRevertsOnNonHexFirstPayloadCharacter,testBytesToHexRevertsOnNonHexLastPayloadCharacter,testBytesToHexRevertsOnCharacterBelowDigitRange,testBytesToHexRevertsOnCharacterAboveDigitRange,testBytesToHexRevertsOnCharacterBelowLetterRange,testBytesToHexRevertsOnCharacterAboveLetterRange, plus the fuzz property — each fails with the tests kept andsrc/lib/LibHexString.solreverted tomain's version, transcript in section 1.testBytesToHexAcceptsEveryHexNibblepasses againstmain's library by design; it is the over-tightening guard, and it is what kills M9-M12.Vm.toString(bytes)(two lower case hexadecimal nibbles per input byte) and from what ahex"..."literal in generated Solidity will accept — not read back offLibHexString. The hand-written cases assert against literal expected strings and the four range boundaries chosen from ASCII (/:backtickgreject;09afaccept), and the fuzz property recomputes conformance in Solidity from that same definition rather than calling the library twice.bytesToHex, 9 tests) and B (charset clause inconforms, mutation M13). LibHexString.bytesToHex documents a hex-charset guarantee it does not check, and the fuzz oracle encodes the same gap #68 also offers the alternative of narrowing the docs instead; declined with the ledger above, and the docstrings are still corrected because their old wording overclaimed in a way no shape check can deliver.Everything below was re-run with
nix develop -cfrom the flake ateb344aa,which merges
mainat89cb0a2(post-#56, post-#102, post-#126, post-#138).The transcripts this section used to carry were run against
test/lib/LibHexString.bytesToHex.t.solandforge-std-1.16.1, neither of whichexists now, and are replaced rather than kept. Every restore in the harness is
git restore --staged --worktree, becausegit checkout <ref> -- FILEalsowrites the index and a later plain
git checkout -- FILEthen silently restoresthe wrong version; the harness also asserts which version of each file is on disk
before every measurement. The persisted fuzz corpus is deleted before every run,
so no kill below is a replay of an earlier row's counterexample.
Scripts:
pr-108-failfirst.sh,pr-108-mutate.sh,pr-108-mutate-names.sh,pr-108-verify.sh.1. Failing first — tests kept,
src/lib/LibHexString.solreverted tomain.nix develop -c forge test --match-contract LibHexStringBytesToHexTest:The
[tree: ...]line is the harness asserting, by grep, which version of each of thetwo files is on disk before it measures. The nameless ninth
[FAILrow is the fuzzproperty: forge prints a fuzz test's name after its counterexample, and the harness
strips from
counterexampleonward to keep the transcript short, which takes the namewith it. Section 2 runs that property on its own, so the ninth failure is not taken on
trust. 31 + 9 = 40, the whole contract, so this is not a filter that matched nothing.
Unmutated baseline for the same command is
Suite result: ok. 40 passed; 0 failed.2. The fuzz oracle fails first on its own, with its charset clause in and the
library still
main's, at three seeds from a clean corpus:nix develop -c forge test --match-test testBytesToHexStripsOrRevertsForEveryVmOutput --fuzz-seed $s:Each counterexample is a constructed accept-arm case — the third argument is
conforming,truein all three, so the stubVmreturns"0x"followed by a payloadof the right length repeating
filler("<v","bytes32 constant ","memory allocated after the call was corrupted"). Right length, right prefix, non-hex payload:main's library returns where the strengthened oracle now requires a revert. The mirror of this run — oracle charset rule removed, libraryfixed — fails the other way at the same three seeds, with
UnexpectedHexString("0x<v<v<v…", 36),UnexpectedHexString("0xbytes32 constant …", 66)and
UnexpectedHexString("0xmemo", 6). That is row M13 of the matrix, run alone.3. Passing after the fix.
nix develop -c forge test(whole suite):mainat89cb0a2, same command, same checkout:The 9 added here are the whole difference.
4.
nix develop -c forge fmt --check→ exit 0, no diff.git statusclean.5. Coverage.
nix develop -c forge coverage --no-match-coverage "test|script":6. Mutation matrix. Each row breaks exactly one line, re-runs the whole
LibHexStringBytesToHexTestcontract, and restores the line. The unmutatedbaseline is
Suite result: ok. 40 passed; 0 failed, and every row below reportsa suite result over 40 tests, so no row is a filter that matched nothing.
srchexCharset = false;→hexCharset = true;srcif and(hexCharset, eq(shr(240, mload(add(hexString, 0x20))), 0x3078)) {→if eq(shr(240, mload(add(hexString, 0x20))), 0x3078) {srcfor (uint256 i = 2; i < returned.length; i++)→i = 3…RevertsOnNonHexFirstPayloadCharacter, the four range-boundary tests + the fuzz propertysrci + 1 < returned.length…RevertsOnNonHexLastPayloadCharacter+ the fuzz propertysrcc >= 0x30→c >= 0x2f/…RevertsOnCharacterBelowDigitRange+ the fuzz propertysrcc <= 0x39→c <= 0x3a:…RevertsOnCharacterAboveDigitRange+ the fuzz propertysrcc >= 0x61→c >= 0x60…RevertsOnCharacterBelowLetterRangesrcc <= 0x66→c <= 0x67g…RevertsOnCharacterAboveLetterRangesrcc >= 0x61→c >= 0x41A, so everything fromAtofpasses — upper caseA-F, but alsoG-Zand the six punctuation code points betweenZanda…RevertsOnUpperCaseVmOutput,…OnCharacterBelowLetterRange,…OnNonHexVmOutput,…OnNonHexFirstPayloadCharacter,…OnNonHexLastPayloadCharacter+ the fuzz propertysrcc >= 0x30→c >= 0x310testBytesToHexAcceptsEveryHexNibbleamong themsrcc <= 0x39→c <= 0x389testBytesToHexAcceptsEveryHexNibbleamong themsrcc >= 0x61→c >= 0x62atestBytesToHexAcceptsEveryHexNibbleamong themsrcc <= 0x66→c <= 0x65ftestBytesToHexAcceptsEveryHexNibbleamong themtestfor (uint256 i = 2; conforms && i < returned.length; i++)→for (uint256 i = 2; i < 2; i++)testBytesToHexStripsOrRevertsForEveryVmOutputsrcissrc/lib/LibHexString.sol,testistest/src/lib/LibHexString.bytesToHex.t.sol. Passed + failed is 40 on every row.No mutant survived.
Three things the matrix is arranged to show beyond bare kill counts:
one. A suite that only asserted some non-hex character is rejected would let
M4-M7 survive; killing them is what pins the boundaries themselves. M9-M12 are the
over-tightening rows, and they exist because a guard is a hazard if nothing tests
that it still accepts every legal input:
testBytesToHexAcceptsEveryHexNibbleisamong the killers of all four, which is what makes it worth its place next to eight
reject tests.
discards its verdict, the other leaves the verdict computed and ignores it at the
and(hexCharset, …). Both are killed by the same nine, so the tests bind thescan and the gate together rather than either alone.
from the fuzz oracle while the library stays fixed has to fail, or the oracle is
weaker than the code and LibHexString.bytesToHex documents a hex-charset guarantee it does not check, and the fuzz oracle encodes the same gap #68's second half is unaddressed. It fails.
M1a is the one row where the mutation is not a 1-for-1 textual swap:
hexCharset = true;already appears once as the declaration, so the harness logged
occurrences before=1 after=2. Every other row isbefore=1 after=1.