Skip to content

[RLH-36] [INFO] README/NatSpec claims with no pinning test (residue): the packed-collision motivating example, 'a Foo is ALWAYS 4 words' with populated members, deterministic boundary lengths, pointer non-determinism, bytes1[] as a word list #76

Description

@thedavidmeister

Pass 2 residue on a ledgered repo (all 37 library mutants killed; code unchanged since). These are claim-witness tests: each pins a sentence the docs make, passes at HEAD, and adds no mutant-discriminating coverage beyond the existing fuzz-vs-builtin oracles (verified for the first: given hashBytes == keccak256(data) and combineHashes == keccak256(a||b), inequality follows from collision resistance). File them only if the repo's "every README claim has a test" bar is wanted for these too; otherwise close.

  • NatSpec L14-21/L33-36: "abc"+"def" vs "ab"+"cdef" pack identically and the composition separates every pair — no test builds two splits of one byte string. Proposed testCompositionSeparatesPackedCollision(bytes s, uint256 i, uint256 j) + testCompositionSeparatesAbcDef() (verified 2/2; carry over the slice helper the fuzz test needs).
  • README L277-279 "a Foo is ALWAYS 4 words": testFooIsFourWords uses empty members; add the fuzzed testFooIsFourWordsWhateverItsMembersHold(uint256, address, uint256[], bytes).
  • NatSpec word-boundary equalities are pinned deterministically only at 0 and 2 words / 3 bytes; the singleton word list (hashWords([x]) == hashBytes(32 bytes of x), unlike the seeded fold) is stated nowhere: testHashBytesBoundaryLengths() over {0,1,31,32,33,64} and testSingletonWordListIsItsWord(bytes32).
  • README L382-384 "a pointer ... is not even deterministic", the reason for the section: no test shows two equal-valued Foos at different addresses hashing apart as a raw region and together under the composition (testEqualFoosHashApartByRegionAndTogetherByComposition, verified passing).
  • README L317-318 names bytes1[] as a word list for hashing; only its layout is tested: testHashBytes1ArrayAsWordList(bytes1[]) with the README Yul against keccak256(abi.encodePacked(bar_)).

Findings from the audit run

F73 [LOW] dim 2 cat — src/LibHashNoAlloc.sol:14-21, 33-36

The library's motivating claim — composition separates what abi.encodePacked collides — has no test

NatSpec opens with "abc"+"def" vs "ab"+"cdef" packing identically and the hash-of-hashes composition separating them for all pairs; no test exercises two splits of one byte string through combineHashes(hashBytes, hashBytes) and asserts inequality.

EVIDENCE: src L15-16, L33-36; grep -rn 'cdef\|"def"' test/ -> none.

FIX: Add testCompositionSeparatesPackedCollision(bytes s, uint256 i, uint256 j) (two distinct split points: packed equal, abi.encode differs, combined hashes differ) and testCompositionSeparatesAbcDef() to test/LibHashNoAllocCrossType.t.sol (text in d2:coverage:claims#4, verified passing).

F75 [INFO] dim 2 cat — test/MemoryLayout.t.sol:148-168

'a Foo is ALWAYS 4 words' is pinned only for empty and zero-initialised members

testFooIsFourWords uses an empty array and empty bytes; testFooListIsWordList measures zero-initialised Foos; no test measures a Foo with populated c and d, the case ALWAYS is about.

EVIDENCE: README L277-279; MemoryLayout L151-152 new uint256[](0); bytes memory d = "";.

FIX: Add testFooIsFourWordsWhateverItsMembersHold(uint256, address, uint256[] memory, bytes memory) asserting ptr == fmpBefore and fmpAfter - ptr == 0x80 (text in d2:coverage:claims#7).

F77 [INFO] dim 2 cat — test/LibHashNoAlloc.t.sol:19-21, 47-49

Byte-length and word-count boundaries (1, 31, 32, 33, 64 bytes; 1 word) are reached only if the fuzzer samples them

The NatSpec word-boundary equalities (L89-90, L104-107) are pinned deterministically only at length 0 and 2 words / 3 bytes; the singleton word list — where hashWords([x]) IS keccak256 of the bare word x, unlike the seeded fold — is stated nowhere.

EVIDENCE: L19-21, L47-49 fuzz only; fixed cases L24, L56, L82 (empty) and L28, L60, L86; CrossType L27 fuzz length, L41 n=2, L75-80 n=0. README L463-464, L513-515.

FIX: Add testHashBytesBoundaryLengths() over {0,1,31,32,33,64} with hashWords equality at whole-word lengths (d2:coverage:src#4) and testSingletonWordListIsItsWord(bytes32 x) through hashWords x2, hashBytes and the static word (d5:correctness:LibHashNoAllocCrossType.t.sol#1); optionally a README 'Across types' bullet for the singleton.

F78 [INFO] dim 2 cat — README.md:382-384

'A pointer ... is not even deterministic' — the reason for the pointer-handling section — has no test

No test constructs two equal-valued Foos at different addresses and shows they hash apart as a raw region and together under the composition.

EVIDENCE: README L382-384; test/HashPattern.t.sol L40 is the only pointer-word hash assertion.

FIX: Add testEqualFoosHashApartByRegionAndTogetherByComposition(uint256, address, uint256[], bytes) with a hashFooSteps oracle (text in sweep1:d2#2, verified passing).

F80 [INFO] dim 2 cat — README.md:317-318

'single byte values bytes1[]' is named as a word list for hashing but only its layout, never its hash, is tested

testHashWordList applies the README word-list Yul to uint256[] only; the claim that left-aligned bytes1 words hash as abi.encodePacked(bytes1[]) is unpinned.

EVIDENCE: README L317-318; HashPattern.t.sol L110 testHashWordList(uint256[] memory bar_); MemoryLayout L172-201 layout only.

FIX: Add testHashBytes1ArrayAsWordList(bytes1[] memory bar_) with the README Yul asserting keccak256(abi.encodePacked(bar_)) (sweep1:d2#5).

Fix texts

fix text from sweep1:d2#2

FIX:
Add to test/HashPattern.t.sol:

/// README "Handling pointers": two Foos with equal members at different
/// addresses hash apart as a raw region, because the region holds their
/// pointer words, and hash together under the pointer-following steps.
function testEqualFoosHashApartByRegionAndTogetherByComposition(
    uint256 a,
    address b,
    uint256[] memory c,
    bytes memory d
) public pure {
    Foo memory first = Foo(a, b, c, d);
    uint256[] memory c2 = new uint256[](c.length);
    for (uint256 i = 0; i < c.length; i++) {
        c2[i] = c[i];
    }
    bytes memory d2 = bytes.concat(d);
    Foo memory second = Foo(a, b, c2, d2);
    bytes32 regionFirst;
    bytes32 regionSecond;
    assembly ("memory-safe") {
        regionFirst := keccak256(first, 0x80)
        regionSecond := keccak256(second, 0x80)
    }
    assertTrue(regionFirst != regionSecond);
    assertEq(hashFooSteps(first), hashFooSteps(second));
}

/// Steps A to E of README "Handling pointers" over one Foo.
function hashFooSteps(Foo memory foo) internal pure returns (bytes32) {
    bytes32 hashA = keccak256(abi.encode(foo.a, foo.b));
    bytes32 hashB = keccak256(abi.encodePacked(foo.c));
    bytes32 hashC = keccak256(abi.encodePacked(hashA, hashB));
    bytes32 hashD = keccak256(foo.d);
    return keccak256(abi.encodePacked(hashC, hashD));
}

fix text from sweep1:d2#5

FIX:
Add to test/HashPattern.t.sol:

/// "single byte values `bytes1[]`" are a word list: the word-list example
/// hashes the `length` left-aligned words after the prefix, which is what
/// `abi.encodePacked` of a `bytes1[]` lays out.
function testHashBytes1ArrayAsWordList(bytes1[] memory bar_) public pure {
    bytes32 hash_;
    assembly ("memory-safe") {
        // Assume bar_ is some dynamic length list of words
        hash_ := keccak256(
            // Skip the length prefix
            add(bar_, 0x20),
            // Read the length prefix and multiply by 0x20 to know how many _words_
            // to hash
            mul(mload(bar_), 0x20)
        )
    }
    assertEq(hash_, keccak256(abi.encodePacked(bar_)));
}

Whole-repo audit of rainlanguage/rain.lib.hash at 1e6e59f3d1fc4fd30c86a5836aa156eed382e729 (main, 2026-09-06; audit skill 0.35.0 at claude-audit-skills 9fefd09902d777aabc967ef6625d038e6c2101b7). Pass-2 gate: ledgered (adversarial-mutation-test record 2026-09-03 at 5055221444d547aba034b88fbd2525973b17d9a9; library code unchanged since). Fixes marked verified were applied to a scratch copy and run with forge in the rainix sol-shell (dbcd9d3c) during the audit; everything else is proposed only. Full run record: /home/gildlab/artifacts/rain.lib.hash-audit-2026-09-06/.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

auditAudit findinginfoAudit findingpass2Audit finding

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions