Skip to content

An empty meta file is hashed like any other, to keccak256("") - #103

Merged
thedavidmeister merged 4 commits into
mainfrom
2026-08-16-issue-70
Aug 17, 2026
Merged

An empty meta file is hashed like any other, to keccak256("")#103
thedavidmeister merged 4 commits into
mainfrom
2026-08-16-issue-70

Conversation

@thedavidmeister

@thedavidmeisterthedavidmeister commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes#70.

What an empty meta file produces

describedByMetaHashConstantString reads meta/<name>.rain.meta and hashes
whatever bytes it finds there. A file that holds no bytes hashes to
keccak256(""), and that is emitted as the constant like any other hash:

/// @dev The hash of the meta that describes the contract.bytes32constant DESCRIBED_BY_META_HASH =bytes32(0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470);

0xc5d2…a470 is keccak256(""). That is a measured value, printed by the suite
itself under M1 below, not an assumed one.

#70 asked whether that is intended or an oversight, and offered both branches:
pin it, or guard it. It is intended, so this is the pin.
src/lib/LibCodeGen.sol is byte for byte identical to main in this PR — the
whole change is the suite stating what the library does, and why it deliberately
differs from the sibling that refuses the same value.

Two tests carry it:

  • testDescribedByMetaHashConstantStringHashesEmptyMeta — writes a zero-byte
    fixture and pins the emitted declaration against keccak256(""). Its
    docstring is the deliverable No test states what an empty meta/<name>.rain.meta produces from describedByMetaHashConstantString #70 asked for: the written-down contrast with
    bytecodeHashConstantString's codeless refusal.
  • testDescribedByMetaHashConstantStringHashesAnyLength — a one-byte file gives
    keccak256(hex"00"), so length is not something this function inspects. There
    is no minimum, no padding, and no check that the bytes are meta. The empty
    case is just the length-zero instance of the same rule.

The guard was considered and rejected

An earlier revision of this branch took #70's other route: it added
error EmptyMeta(string path) to LibCodeGen and reverted when the meta file
held zero bytes. That is gone, along with the NatSpec paragraph that documented
the refusal and the EmptyMeta import in the test. Anyone arriving from #70
looking for the guard should find the reason here rather than a silent omission.

  1. The CodelessInstance analogy that motivated it does not hold. Bytecode
    is necessarily unique per contract, so a codehash of nothing pinned against
    an address names no deployment at all, and an address holding no code is
    the address a caller lands on when they meant to deploy something — a mistake
    in the call itself. Meta is documentation. Absent or shared documentation is
    a normal state of a project, not a caller mistake: two contracts described
    the same way legitimately carry the same meta hash.

  2. "The same value whatever contract is being generated" is not a property of
    emptiness.
    keccak256(hex"00") is equally shared by every contract whose
    meta file holds that one byte, and the guard accepted it — the accept-side
    test on the earlier revision demonstrated exactly that, which put the guard's
    stated justification in contradiction with its own second test.

  3. keccak256("") is the correct hash of the actual bytes. A consumer
    checking a blob against DESCRIBED_BY_META_HASH and finding the blob empty
    gets a true answer; nothing is falsely authenticated. An honest empty state
    is correct output, not a gap for library logic to route around.

  4. The cost is real and lands on consumers. It is a hard revert in a
    published library. Every consumer whose meta file is not written yet stops
    being able to generate at all, turning a soft documentation gap into a build
    failure. It also collides with meta/ holding committed artifacts (Hold .soldeerignore against the repo root, and stop shipping .audit/ #133): a
    committed empty placeholder becomes impossible to have.

QA

Toolchain is the flake's throughout (nix develop -c …), in a fresh clone,
branch 2026-08-16-issue-70. origin/main is merged in at d00e401 — merged,
never rebased — and everything below was run on the merge commit rather than a
conflict check alone.

Test counts.main at d00e401: Ran 19 test suites in 1.38s (14.25s CPU time): 145 tests passed, 0 failed, 0 skipped (145 total tests). This branch on
the merge commit: Ran 19 test suites in 1.40s (15.38s CPU time): 147 tests passed, 0 failed, 0 skipped (147 total tests). 145 → 147 is the two tests
above.

forge fmt --check → exit 0, no diff.

Coverage, forge coverage --no-match-coverage "test|script" — unchanged from
main:

| File | % Lines | % Statements | % Branches | % Funcs |
| src/lib/LibCodeGen.sol | 100.00% (49/49) | 100.00% (62/62) | 100.00% (3/3) | 100.00% (14/14) |
| src/lib/LibFs.sol | 100.00% (16/16) | 100.00% (14/14) | 100.00% (4/4) | 100.00% (3/3) |
| src/lib/LibHexString.sol | 100.00% (14/14) | 100.00% (16/16) | 100.00% (3/3) | 100.00% (1/1) |
| Total | 100.00% (79/79) | 100.00% (92/92) | 100.00% (10/10) | 100.00% (18/18) |

The pin can fail

A pin nobody proved can fail is not a pin. Both new tests were broken on purpose
from the committed tree, and the library was changed underneath them, one case
at a time with git checkout between. Each mutant is a patch applied through
git apply --check first, so one that no longer applies aborts the run instead
of reading as "survived", and git diff --numstat is recorded after applying so
an empty diff would be visible. Each row carries the suite's own totals line, so
a harness error cannot masquerade as a pass. The script and its patches live in
this clone's gitignored cache/.

Starting tree clean at the merge commit; every row restored before the next;
unmutated recheck at the end.

#MutationnumstatSuiteKilled by
M1pin test: asserted keccak256("")keccak256(hex"00")1 1146 passed, 1 failedHashesEmptyMetaassertion failed
M2any-length test: asserted keccak256(meta)keccak256("")1 1146 passed, 1 failedHashesAnyLengthassertion failed
M3library: empty meta emits bytes32(0) instead of hashing it1 1146 passed, 1 failedHashesEmptyMetaassertion failed
M4library: re-introduce the zero-length revert1 0146 passed, 1 failedHashesEmptyMeta[FAIL: empty meta]
M5library: a one byte file is hashed as if empty3 0146 passed, 1 failedHashesAnyLengthassertion failed
unmutated recheckempty147 passed, 0 failed

M1 is the direct proof asked of a pin, and it prints the real emitted value
next to the wrong expectation:

Failing tests:
Encountered 1 failing test in test/lib/LibCodeGen.describedByMetaHashConstantString.t.sol:LibCodeGenDescribedByMetaHashConstantStringTest
[FAIL: assertion failed:
/// @dev The hash of the meta that describes the contract.
bytes32 constant DESCRIBED_BY_META_HASH = bytes32(0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470);
!=
/// @dev The hash of the meta that describes the contract.
bytes32 constant DESCRIBED_BY_META_HASH = bytes32(0xbc36789e7a1e281436464229828f817d6612f7b477d66591ff96a9e064bcc98a);
] testDescribedByMetaHashConstantStringHashesEmptyMeta() (gas: 32210)
Encountered a total of 1 failing tests, 146 tests succeeded

The left-hand side is what the library emits — keccak256("") — and the
right-hand side is keccak256(hex"00"), the mutated expectation.

M3 and M4 are the two ways the pinned behaviour could be walked back in the
library: routing the empty case to a different value, and refusing it outright.
Only the pin test catches either, which is what earns it its place beyond
restating the code. M5 is what earns HashesAnyLength its place independently:
it is the sole killer of a library that folds a one-byte file into the empty
case, which the pin test alone does not see.

🤖 Generated with Claude Code

`describedByMetaHashConstantString` reverts `EmptyMeta` when the file it
reads holds no bytes. Such a file hashes to `keccak256("")`, the same
value whatever contract is being generated, so a `DESCRIBED_BY_META_HASH`
carrying it names no meta rather than the meta that describes the
contract. This is the refusal `bytecodeHashConstantString` already makes
for an account that exists and holds no code, which reaches the same
`keccak256("")` from the other side.
Contents of any length at all are hashed as they are: the refusal is on
the file holding nothing, not on the contents being something other than
meta. A file that is absent rather than empty is refused by the read.
The error carries the path rather than the name, because the path is the
file to go and fix and the name is recoverable from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeisterthedavidmeister self-assigned this Aug 16, 2026
@coderabbitai

coderabbitaiBot commented Aug 16, 2026

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:52 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 @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: bf46c8b7-4fe0-4e55-8518-448e4c540bfd

📥 Commits

Reviewing files that changed from the base of the PR and between d00e401 and 2319d4a.

📒 Files selected for processing (1)
  • test/lib/LibCodeGen.describedByMetaHashConstantString.t.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.

thedavidmeisterand others added 3 commits August 17, 2026 04:04
…ing it
An empty `meta/<name>.rain.meta` hashes to `keccak256("")` and is emitted as
`DESCRIBED_BY_META_HASH` like any other file's hash. That is the correct hash of
the bytes that are there, so the suite states it instead of the library refusing
it.
Drops the `EmptyMeta` guard an earlier revision of this branch added, and its
NatSpec, leaving `src/lib/LibCodeGen.sol` identical to `main`. The contrast with
`bytecodeHashConstantString`, which does refuse the codeless address that
arrives at the same value, is written down in the pinning test's docstring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeisterthedavidmeister changed the title A meta file that holds no bytes is refused rather than hashed to keccak256("")An empty meta file is hashed like any other, to keccak256("")Aug 17, 2026
@thedavidmeister
thedavidmeister merged commit 403f560 into mainAug 17, 2026
4 checks passed
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.

No test states what an empty meta/<name>.rain.meta produces from describedByMetaHashConstantString

1 participant

@thedavidmeister