diff --git a/.gitignore b/.gitignore index 376451e21..0c5c66429 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ docs # This is for our deploy scripts that report the addresses of deployed contracts deployments +.fixes/ diff --git a/CLAUDE.md b/CLAUDE.md index be24b9afb..92639637d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,7 @@ Testing patterns and conventions are in `TESTING.md`. Read that file before writ - Test files are in `test/` mirroring `src/` structure, suffixed `.t.sol` - Rust test fixtures (`crates/test_fixtures/`) deploy all four contracts on a local Anvil instance +- Always run test commands with `run_in_background: true` so work continues in parallel ## Process (Jidoka) diff --git a/Cargo.lock b/Cargo.lock index c81d3f32b..f85982563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5489,8 +5489,6 @@ dependencies = [ "alloy-ethers-typecast", "rain_interpreter_bindings", "rain_interpreter_dispair", - "serde", - "serde_json", "thiserror 1.0.69", "tokio", ] @@ -5501,7 +5499,6 @@ version = "0.0.0" dependencies = [ "alloy", "getrandom 0.2.16", - "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0e11e795b..7c7d579af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "CAL-1.0" -homepage = "https://github.com/rainprotocol/rain.interpreter" +homepage = "https://github.com/rainlanguage/rain.interpreter" [workspace.dependencies] alloy = { version = "1.0.9", features = ["sol-types", "json", "json-abi"] } diff --git a/audit/2026-02-17-03/triage.md b/audit/2026-02-17-03/triage.md index e6d2ce7db..70b9a585a 100644 --- a/audit/2026-02-17-03/triage.md +++ b/audit/2026-02-17-03/triage.md @@ -236,62 +236,62 @@ Tracks the disposition of every LOW+ finding from pass2 audit reports (test cove - [FIXED] A24-1: (LOW) LibOpE missing operand disallowed test — added testOpEEvalOperandDisallowed - [FIXED] A24-2: (LOW) LibOpExp and LibOpExp2 fuzz tests restrict inputs to non-negative small values only — added testOpExpEvalNegativeInput (exp(-1)=1/e) and testOpExp2EvalNegativeInput (exp2(-1)=0.5) - [FIXED] A24-3: (LOW) LibOpGm fuzz test restricts inputs to non-negative small values only — fixed implementation to compute signed geometric mean: sign * sqrt(|a| * |b|). Expanded fuzz bounds to include negatives. Added eval tests for mixed signs (unit and non-unit), both negative (equal and unequal), zero with negative, and zero bytes identity. Signed GM function upstreamed to rain.math.float as GitHub issue. -- [PENDING] A24-4: (LOW) LibOpFloor eval tests missing negative value coverage -- [PENDING] A25-1: (LOW) LibOpInv missing test for division by zero (inv(0)) -- [PENDING] A25-2: (LOW) LibOpSub missing zero outputs and two outputs tests -- [PENDING] A25-3: (LOW) LibOpSub missing operand handler test -- [PENDING] A25-4: (LOW) LibOpMin missing zero outputs and two outputs tests -- [PENDING] A25-5: (LOW) LibOpMax missing zero outputs test -- [PENDING] A25-6: (LOW) LibOpSqrt missing test for negative input error path -- [PENDING] A26-1: (LOW) Missing operand disallowed test for LibOpBlockNumber -- [PENDING] A26-2: (LOW) Missing operand disallowed test for LibOpChainId -- [PENDING] A26-3: (LOW) Missing operand disallowed test for LibOpTimestamp -- [PENDING] A28-1: (LOW) No test for get() caching side effect on read-only keys -- [PENDING] A29-1: (LOW) LibOpMaxUint256 missing operand disallowed test +- [FIXED] A24-4: (LOW) LibOpFloor eval tests missing negative value coverage — added testOpFloorEvalNegative covering floor(-1), floor(-1.1)=-2, floor(-0.5)=-1, floor(-1.5)=-2, floor(-2), floor(-2.5)=-3. Uses float equality for fractional cases where internal normalization differs from packLossless. +- [FIXED] A25-1: (LOW) LibOpInv missing test for division by zero (inv(0)) — added testOpInvEvalDivisionByZero verifying inv(0) reverts with DivisionByZero, and testOpInvEvalNegative for inv(-1)=-1, inv(-2)=-0.5 +- [FIXED] A25-2: (LOW) LibOpSub missing zero outputs and two outputs tests — added testOpSubZeroOutputs and testOpSubTwoOutputs using checkBadOutputs +- [FIXED] A25-3: (LOW) LibOpSub missing operand handler test — added testOpSubEvalTwoOperandsDisallowed verifying two-operand syntax <0 0>, <0 1>, <1 0> reverts with UnexpectedOperandValue +- [FIXED] A25-4: (LOW) LibOpMin missing zero outputs and two outputs tests — added testOpMinZeroOutputs and testOpMinTwoOutputs +- [FIXED] A25-5: (LOW) LibOpMax missing zero outputs test — added testOpMaxZeroOutputs +- [FIXED] A25-6: (LOW) LibOpSqrt missing test for negative input error path — added testOpSqrtEvalNegativeInput verifying sqrt(-1) reverts with PowNegativeBase +- [FIXED] A26-1: (LOW) Missing operand disallowed test for LibOpBlockNumber — added testOpBlockNumberEvalOperandDisallowed +- [FIXED] A26-2: (LOW) Missing operand disallowed test for LibOpChainId — added testOpChainIdEvalOperandDisallowed +- [FIXED] A26-3: (LOW) Missing operand disallowed test for LibOpTimestamp — added testOpBlockTimestampEvalOperandDisallowed and testOpNowEvalOperandDisallowed as separate tests +- [DISMISSED] A28-1: (LOW) No test for get() caching side effect on read-only keys — already tested: testLibOpGetRunUnset verifies stateKV populated on miss (lines 59-64), testLibOpGetEvalKeyNotSet verifies kvs output includes cached value (lines 214-216). Finding acknowledges behavior is by design. +- [FIXED] A29-1: (LOW) LibOpMaxUint256 missing operand disallowed test — added testOpMaxUint256EvalOperandDisallowed - [FIXED] A30-1: (MEDIUM) No test triggers `ParenOverflow` error — testParenOverflow and testParenMaxNesting boundary tests added -- [PENDING] A30-2: (LOW) No test triggers `ParserOutOfBounds` error from `parse()` -- [PENDING] A30-3: (LOW) No test for yang-state `UnexpectedRHSChar` in `parseRHS` -- [PENDING] A30-4: (LOW) No test for stack name fallback path in `parseRHS` via `stackNameIndex` -- [PENDING] A30-5: (LOW) No test for `OPCODE_UNKNOWN` sub-parser bytecode construction boundary conditions -- [PENDING] A31-1: (LOW) No direct unit tests for `parseErrorOffset` -- [PENDING] A31-2: (LOW) No direct unit tests for `handleErrorSelector` -- [PENDING] A32-1: (LOW) No direct unit tests for `skipComment`, `skipWhitespace`, or `parseInterstitial` +- [DISMISSED] A30-2: (LOW) No test triggers `ParserOutOfBounds` error from `parse()` — untestable defensive invariant. All cursor advancement in parseInterstitial, parseLHS, parseRHS is bounded by end checks; no code path can advance cursor past end under the current implementation. +- [FIXED] A30-3: (LOW) No test for yang-state `UnexpectedRHSChar` in `parseRHS` — added testParseUnexpectedRHSYangWordWord verifying consecutive words without whitespace reverts +- [FIXED] A30-4: (LOW) No test for stack name fallback path in `parseRHS` via `stackNameIndex` — added testParseNamedLHSStackNameOnly (sole RHS item) and testParseNamedLHSStackNameLastPosition (last position after literal) +- [FIXED] A30-5: (LOW) No test for `OPCODE_UNKNOWN` sub-parser bytecode construction boundary conditions — added testUnknownWordMaxLength (31-byte word), testUnknownWordMinLength (1-byte word), testUnknownWordWithOperandValues (operand data appended) +- [FIXED] A31-1: (LOW) No direct unit tests for `parseErrorOffset` — added LibParseError.t.sol with testParseErrorOffsetFirstByte, testParseErrorOffsetLastByte, testParseErrorOffsetFuzz +- [FIXED] A31-2: (LOW) No direct unit tests for `handleErrorSelector` — added testHandleErrorSelectorReverts (non-zero selector) and testHandleErrorSelectorZeroNoOp (zero selector no-op) +- [FIXED] A32-1: (LOW) No direct unit tests for `skipComment`, `skipWhitespace`, or `parseInterstitial` — added testSkipCommentSetsYang, testSkipWhitespaceClearsYang, testSkipWhitespaceAtEnd, testParseInterstitialMixed, testParseInterstitialAtEnd - [FIXED] A32-2: (MEDIUM) `MalformedCommentStart` error path is never tested — fuzzed over all non-'*' second bytes -- [PENDING] A32-3: (LOW) No test for `skipComment` when `cursor + 4 > end` -- [PENDING] A32-4: (LOW) No test for `skipWhitespace` in isolation +- [FIXED] A32-3: (LOW) No test for `skipComment` when `cursor + 4 > end` — added testSkipCommentTooShort (2 bytes) and testSkipCommentThreeBytes (3 bytes) +- [FIXED] A32-4: (LOW) No test for `skipWhitespace` in isolation — added testSkipWhitespaceClearsYang and testSkipWhitespaceAtEnd - [FIXED] A33-1: (MEDIUM) No direct unit test for `selectLiteralParserByIndex` — added direct test calling returned function pointer for hex, decimal, and string indices -- [PENDING] A33-2: (LOW) No direct unit test for `tryParseLiteral` dispatch logic -- [PENDING] A33-3: (LOW) No test for `parseLiteral` revert path +- [FIXED] A33-2: (LOW) No direct unit test for `tryParseLiteral` dispatch logic — added LibParseLiteral.dispatch.t.sol with 24 tests covering all dispatch paths, value correctness, cursor advancement, and edge cases +- [FIXED] A33-3: (LOW) No test for `parseLiteral` revert path — added testParseLiteralUnsupportedType and testParseLiteralHappyPath in same file - [FIXED] A34-1: (MEDIUM) No happy-path unit test for `parseDecimalFloatPacked` — added 47 happy-path cases covering zero, integers, negatives, positive/negative exponents, decimal points, no exponent, and large coefficients using float eq -- [PENDING] A34-2: (LOW) No fuzz test for decimal parsing round-trip -- [PENDING] A34-3: (LOW) No test for cursor position after successful parse -- [PENDING] A34-4: (LOW) No test for decimal values with fractional parts +- [UPSTREAM] A34-2: (LOW) No fuzz test for decimal parsing round-trip — covered by testParseLiteralDecimalFloatFuzz in rain.math.float/test/src/lib/parse/LibParseDecimalFloat.t.sol:108 +- [UPSTREAM] A34-3: (LOW) No test for cursor position after successful parse — covered by checkParseDecimalFloat helper in same upstream file which asserts cursorAfter on every call including fuzz test +- [FIXED] A34-4: (LOW) No test for decimal values with fractional parts — already covered by A34-1 fix (1.5, 0.001, 123.456, 0.1, 99.99, etc.) - [FIXED] A35-1: (MEDIUM) No test for `HexLiteralOverflow` error — testParseHexOverflow with boundary at 65 digits - [FIXED] A35-2: (MEDIUM) No test for `ZeroLengthHexLiteral` error — fuzzed over non-hex trailing bytes - [FIXED] A35-3: (MEDIUM) No test for `OddLengthHexLiteral` error — fuzzed over odd lengths 1-63 -- [PENDING] A35-4: (LOW) No test for `MalformedHexLiteral` error -- [PENDING] A35-5: (LOW) No test for mixed-case hex parsing +- [DISMISSED] A35-4: (LOW) No test for `MalformedHexLiteral` error — unreachable defensive invariant; boundHex uses CMASK_HEX (= CMASK_NUMERIC_0_9 | CMASK_LOWER_ALPHA_A_F | CMASK_UPPER_ALPHA_A_F) to bound the region, same three masks used in nybble conversion +- [FIXED] A35-5: (LOW) No test for mixed-case hex parsing — added testParseHexMixedCase, testParseHexUpperCase, testParseHexLowerCase, testParseHexAlternatingCase - [FIXED] A36-1: (MEDIUM) No test for RepeatLiteralTooLong revert path — added fuzz test for length >= 78 - [FIXED] A36-2: (MEDIUM) No test for parseRepeat output value correctness — added fuzz test asserting output against reference sum -- [PENDING] A36-3: (LOW) No test for zero-length literal body (cursor == end) -- [PENDING] A36-4: (LOW) No test for length = 1 (single character body) -- [PENDING] A36-5: (LOW) No test for length = 77 (maximum valid length) -- [PENDING] A36-6: (LOW) Integration tests use bare vm.expectRevert() without specifying expected error -- [PENDING] A37-1: (LOW) No explicit test for `parseString` memory snapshot restoration -- [PENDING] A37-3: (LOW) No test for `UnclosedStringLiteral` when `end == innerEnd` +- [FIXED] A36-3: (LOW) No test for zero-length literal body (cursor == end) — covered by A36-2 fuzz test which fuzzes length 0-77 +- [FIXED] A36-4: (LOW) No test for length = 1 (single character body) — covered by A36-2 fuzz test +- [FIXED] A36-5: (LOW) No test for length = 77 (maximum valid length) — covered by A36-2 fuzz test +- [FIXED] A36-6: (LOW) Integration tests use bare vm.expectRevert() without specifying expected error — added InvalidRepeatCount selector to all three negative integration tests +- [FIXED] A37-1: (LOW) No explicit test for `parseString` memory snapshot restoration — added testParseStringMemoryRestoration fuzz test that parses twice and verifies values match and data length is intact +- [FIXED] A37-3: (LOW) No test for `UnclosedStringLiteral` when `end == innerEnd` — added testBoundStringUnclosedAtEndBoundary concrete test and fuzz variant targeting end at closing quote - [FIXED] A38-1: (MEDIUM) No test for `subParseLiteral` returning `(false, ...)` (sub-parser rejection) — added fuzz tests for first-rejects-second-accepts and all-reject paths -- [PENDING] A38-2: (LOW) No fuzz test for the error paths +- [DISMISSED] A38-2: (LOW) No fuzz test for the error paths — 9 concrete tests cover every error branch; fuzz would only generate variations hitting the same branches - [FIXED] A39-1: (MEDIUM) `handleOperandDisallowedAlwaysOne` has no test file or any test coverage — added tests for empty values returning 1 and non-empty values reverting -- [PENDING] A39-2: (LOW) `handleOperand` (dispatch function) has no direct unit test -- [PENDING] A39-3: (LOW) `parseOperand` -- no test for `UnclosedOperand` revert from yang state -- [PENDING] A39-5: (LOW) `handleOperandM1M1` -- no test for first value overflow with two values provided -- [PENDING] A39-6: (LOW) `handleOperand8M1M1` -- no test for first value overflow with all three values provided -- [PENDING] A40-1: (LOW) No unit test for `cursor >= end` revert path after keyword -- [PENDING] A40-2: (LOW) No test for multiple pragmas in sequence -- [PENDING] A40-3: (LOW) No test for pragma with comments between addresses -- [PENDING] A41-1: (LOW) No test for bloom filter false positive path -- [PENDING] A41-2: (LOW) No test for fingerprint collision behavior -- [PENDING] A41-3: (LOW) No negative lookup test on populated list +- [FIXED] A39-2: (LOW) `handleOperand` (dispatch function) has no direct unit test — added LibParseOperand.handleOperand.t.sol with 4 tests: empty values, different handlers, same handler, disallowed multiple indices +- [FIXED] A39-3: (LOW) `parseOperand` -- no test for `UnclosedOperand` revert from yang state — added testParseOperandYangStateLiteralCollision with back-to-back literal without whitespace +- [FIXED] A39-5: (LOW) `handleOperandM1M1` -- no test for first value overflow with two values provided — added testHandleOperandM1M1TwoValuesFirstValueTooLarge +- [FIXED] A39-6: (LOW) `handleOperand8M1M1` -- no test for first value overflow with all three values provided — added testHandleOperand8M1M1AllValuesFirstValueTooLarge +- [FIXED] A40-1: (LOW) No unit test for `cursor >= end` revert path after keyword — added testPragmaKeywordEndAtKeyword +- [FIXED] A40-2: (LOW) No test for multiple pragmas in sequence — added testPragmaKeywordTwoSequentialPragmas +- [FIXED] A40-3: (LOW) No test for pragma with comments between addresses — added testPragmaKeywordCommentBetweenAddresses +- [FIXED] A41-1: (LOW) No test for bloom filter false positive path — added testStackNameBloomFalsePositive with brute-forced bloom collision +- [DISMISSED] A41-2: (LOW) No test for fingerprint collision behavior — 224-bit keccak collision is astronomically unlikely; accepted behavior +- [FIXED] A41-3: (LOW) No negative lookup test on populated list — added testStackNameNegativeLookup fuzz test - [FIXED] A42-1: (CRITICAL) No direct unit tests for any function in LibParseStackTracker — LibParseStackTracker.t.sol added with 12 tests - [FIXED] A42-2: (HIGH) ParseStackOverflow in push() never tested — testPushOverflow added - [FIXED] A42-3: (HIGH) ParseStackUnderflow in pop() never tested — testPopUnderflow added @@ -307,38 +307,38 @@ Tracks the disposition of every LOW+ finding from pass2 audit reports (test cove - [FIXED] A43-7: (MEDIUM) No direct unit tests for pushOpToSource() — added 5 tests: encoding fuzz, FSM flags, two-op encoding, slot overflow linked list, SourceItemOpsOverflow - [FIXED] A43-8: (MEDIUM) No direct unit tests for endSource() — added 5 tests: single-op source, state reset, two sources, byte length fuzz, MaxSources revert - [FIXED] A43-9: (MEDIUM) No direct unit tests for buildBytecode() — added 3 tests: single source, two sources, fuzz source count and ops per source -- [PENDING] A43-10: (LOW) No direct unit tests for buildConstants() -- [PENDING] A43-11: (LOW) No direct unit tests for pushLiteral() +- [FIXED] A43-10: (LOW) No direct unit tests for buildConstants() — added 4 tests: empty, single fuzz, multiple concrete, fuzz N values in push order +- [FIXED] A43-11: (LOW) No direct unit tests for pushLiteral() — added 5 tests: single hex, single decimal, duplicate dedup, two different values, UnsupportedLiteralType revert - [FIXED] A44-1: (HIGH) No direct unit test for subParseWordSlice() — all paths covered by integration tests (badSubParserResult.t.sol, unknownWord.t.sol, intInc.t.sol) - [FIXED] A44-2: (MEDIUM) UnknownWord error path tested only via integration — added direct test with mock sub-parser rejection and fuzzed address - [FIXED] A44-3: (MEDIUM) UnsupportedLiteralType error path in subParseLiteral() not directly tested — already covered by A38-1 testSubParseLiteralAllReject in commit `66644c8d` -- [PENDING] A44-4: (LOW) No direct unit test for subParseWords() -- [PENDING] A44-5: (LOW) No direct unit test for subParseLiteral() -- [PENDING] A44-6: (LOW) No direct unit test for consumeSubParseWordInputData() -- [PENDING] A44-7: (LOW) No direct unit test for consumeSubParseLiteralInputData() -- [PENDING] A44-8: (LOW) Sub parser constant accumulation not tested -- [PENDING] A45-1: (LOW) No test for `InputsLengthMismatch` with fewer inputs than expected -- [PENDING] A45-2: (LOW) No direct test for `eval4` happy path with inputs -- [PENDING] A45-3: (LOW) No test for `eval4` with non-zero `sourceIndex` -- [PENDING] A45-5: (LOW) No test for `stateOverlay` with multiple key-value pairs -- [PENDING] A45-6: (LOW) No test for `stateOverlay` with duplicate keys +- [FIXED] A44-4: (LOW) No direct unit test for subParseWords() — added 6 tests: empty bytecode, known opcode passthrough, single/two source resolution via sub parser, unknown word revert, multiple known opcodes +- [FIXED] A44-5: (LOW) No direct unit test for subParseLiteral() — Added `LibSubParse.subParseLiteral.t.sol` (6 tests: single sub parser success/reject, first-rejects-second-accepts, all-reject, empty body, early exit) +- [FIXED] A44-6: (LOW) No direct unit test for consumeSubParseWordInputData() — Added `LibSubParse.consumeSubParseWordInputData.t.sol` (6 tests: basic extraction, fuzz constantsHeight, fuzz ioByte, max constantsHeight, empty word, longer word) +- [FIXED] A44-7: (LOW) No direct unit test for consumeSubParseLiteralInputData() — Added `LibSubParse.consumeSubParseLiteralInputData.t.sol` (7 tests: basic, dispatch content, body content, empty body, minimal, fuzz, roundtrip) +- [FIXED] A44-8: (LOW) Sub parser constant accumulation not tested — Added `LibSubParse.constantAccumulation.t.sol` (4 tests: single constant, two words, multi-constant, constant index after literal) +- [FIXED] A45-1: (LOW) No test for `InputsLengthMismatch` with fewer inputs than expected — already exists at `Rainterpreter.eval.t.sol:testInputsLengthMismatchTooFew` with fuzz over expectedInputs/actualInputs +- [FIXED] A45-2: (LOW) No direct test for `eval4` happy path with inputs — already exists at `Rainterpreter.eval.t.sol:testEvalWithMatchingInputs` with fuzz over inputs a/b +- [FIXED] A45-3: (LOW) No test for `eval4` with non-zero `sourceIndex` — Added `Rainterpreter.eval.nonZeroSourceIndex.t.sol` (3 tests: basic sourceIndex=1, sourceIndex=1 with inputs, source selection comparison) +- [FIXED] A45-5: (LOW) No test for `stateOverlay` with multiple key-value pairs — Added `testStateOverlayMultiplePairs` (2 pairs) and `testStateOverlayThreePairs` (3 pairs) to `Rainterpreter.stateOverlay.t.sol` +- [FIXED] A45-6: (LOW) No test for `stateOverlay` with duplicate keys — Added `testStateOverlayDuplicateKeyLastWins` and `testStateOverlayDuplicateKeysInterleaved` to `Rainterpreter.stateOverlay.t.sol` - [FIXED] A47-1: (MEDIUM) No direct test for `parse2` with invalid input — added 3 tests: empty input, parse error propagation, integrity error propagation - [FIXED] A47-2: (MEDIUM) No direct test for `parsePragma1` on the expression deployer — added 4 tests: no pragma, single address, two addresses, error propagation -- [PENDING] A47-3: (LOW) No test for `buildIntegrityFunctionPointers` return value consistency -- [PENDING] A47-4: (LOW) No test for `parse2` assembly block memory allocation +- [DISMISSED] A47-3: (LOW) No test for `buildIntegrityFunctionPointers` return value consistency — internal function pointers are relative to contract bytecode, cannot be meaningfully compared across different contract deployments; consistency is verified by BuildPointers.sol during the build pipeline +- [DISMISSED] A47-4: (LOW) No test for `parse2` assembly block memory allocation — 4 lines of standard memory allocation boilerplate; any corruption would cause every `parse2` integration test to fail; no way to test allocation independently of output correctness - [FIXED] A48-1: (MEDIUM) No direct test for `unsafeParse` — happy path and empty input tests using LibBytecode inspection -- [PENDING] A48-3: (LOW) No test for `unsafeParse` with input triggering `ParseMemoryOverflow` -- [PENDING] A48-4: (LOW) No test for `parsePragma1` with empty input -- [PENDING] A49-1: (LOW) `InvalidRepeatCount` error not directly asserted in revert tests -- [PENDING] A49-2: (LOW) `BadDynamicLength` error path never tested -- [PENDING] A49-3: (LOW) `SubParserIndexOutOfBounds` error path never tested for RainterpreterReferenceExtern -- [PENDING] A49-4: (LOW) No test for `extern()` function called directly on RainterpreterReferenceExtern -- [PENDING] A49-5: (LOW) No test for `externIntegrity()` called directly on RainterpreterReferenceExtern +- [DISMISSED] A48-3: (LOW) No test for `unsafeParse` with input triggering `ParseMemoryOverflow` — requires pushing free memory pointer past 0x10000 before parse, not practically achievable from test code +- [FIXED] A48-4: (LOW) No test for `parsePragma1` with empty input — Added `RainterpreterParser.parsePragmaEmpty.t.sol` (2 tests: empty input, single null byte) +- [FIXED] A49-1: (LOW) `InvalidRepeatCount` error not directly asserted in revert tests — already exists at `RainterpreterReferenceExtern.repeat.t.sol` with `abi.encodeWithSelector(InvalidRepeatCount.selector)` assertions for negative, non-integer, and >9 cases +- [DISMISSED] A49-2: (LOW) `BadDynamicLength` error path never tested — defensive assertion against compiler memory layout bugs, cannot be triggered from test code +- [FIXED] A49-3: (LOW) `SubParserIndexOutOfBounds` error path never tested for RainterpreterReferenceExtern — Added `RainterpreterReferenceExtern.subParserIndexOutOfBounds.t.sol` with mock subclass forcing OOB literal index +- [FIXED] A49-4: (LOW) No test for `extern()` function called directly on RainterpreterReferenceExtern — already exists at `RainterpreterReferenceExtern.intInc.t.sol:testRainterpreterReferenceExternExternDirect` and `testRainterpreterReferenceExternExternModWrap` +- [FIXED] A49-5: (LOW) No test for `externIntegrity()` called directly on RainterpreterReferenceExtern — already exists at `RainterpreterReferenceExtern.intInc.t.sol:testRainterpreterReferenceExternIntegrityDirect` - [FIXED] A50-1: (MEDIUM) No test for namespace isolation across different `msg.sender` values — added 2 fuzz tests: unidirectional isolation and bidirectional write isolation -- [PENDING] A50-2: (LOW) `Set` event emission never tested -- [PENDING] A50-3: (LOW) No test for `set` with empty array (zero-length `kvs`) -- [PENDING] A50-4: (LOW) No test for `get` on uninitialized key (default value) -- [PENDING] A50-5: (LOW) No test for overwriting a key with a different value in a single `set` call +- [FIXED] A50-2: (LOW) `Set` event emission never tested — Added `RainterpreterStore.setEvent.t.sol` (3 tests: single pair event, multiple pairs events, fuzz FQN matches qualifyNamespace) +- [FIXED] A50-3: (LOW) No test for `set` with empty array (zero-length `kvs`) — Added `RainterpreterStore.setEmpty.t.sol` (3 tests: no revert, no events emitted, fuzz any namespace) +- [FIXED] A50-4: (LOW) No test for `get` on uninitialized key (default value) — Added `RainterpreterStore.getUninitialized.t.sol` (3 tests: returns 0, fuzz namespace+key, unaffected by different key set) +- [FIXED] A50-5: (LOW) No test for overwriting a key with a different value in a single `set` call — Added `RainterpreterStore.overwriteKey.t.sol` (3 tests: last-write-wins, triple overwrite, dupe among unique keys) # Pass 3 Triage @@ -720,30 +720,30 @@ Tracks the disposition of every LOW+ finding from pass4 audit reports (code qual ## Findings - [FIXED] A01-1: (LOW) Dead `using` directives and unused imports (LibStackPointer, LibUint256Array, Pointer) in BaseRainterpreterExtern — removed all 4 using directives and 3 imports -- [PENDING] A01-2: (LOW) Inconsistent assembly idioms for function pointer extraction (`shr(0xf0,...)` vs `and(..., 0xFFFF)`) across BaseRainterpreterExtern and BaseRainterpreterSubParser -- [PENDING] A01-3: (LOW) Error `SubParserIndexOutOfBounds` defined inline in BaseRainterpreterSubParser instead of in `src/error/ErrSubParse.sol` -- [PENDING] A01-4: (LOW) Inconsistent mutability between `opcodeFunctionPointers` (view) and `integrityFunctionPointers` (pure) in BaseRainterpreterExtern +- [DISMISSED] A01-2: (LOW) Inconsistent assembly idioms for function pointer extraction — idiom differences arise from different offset calculations, unifying changes bytecode with no safety benefit +- [FIXED] A01-3: (LOW) Error `SubParserIndexOutOfBounds` defined inline in BaseRainterpreterSubParser instead of in `src/error/ErrSubParse.sol` — moved to ErrSubParse.sol, updated import +- [DISMISSED] A01-4: (LOW) Inconsistent mutability between `opcodeFunctionPointers` (view) and `integrityFunctionPointers` (pure) — view may be intentional for override flexibility - [FIXED] A03-1: (LOW) `MalformedExponentDigits` and `MalformedDecimalPoint` errors are unused dead code in ErrParse.sol — removed both - [FIXED] A03-2: (LOW) Inconsistent NatSpec `@dev` usage across error files; ErrSubParse uses `@dev` while others use plain `///` — removed `@dev` from all 3 errors - [FIXED] A03-3: (LOW) Missing `@param` tags on 28 parameterized errors in ErrParse.sol — added `@param` tags to all parameterized errors - [FIXED] A03-4: (LOW) Missing `@param` tags on `BadOutputsLength` in ErrExtern.sol — added `@param expectedLength` and `@param actualLength` - [FIXED] A03-5: (LOW) Missing `@param` tags on all 3 errors in ErrSubParse.sol — added `@param` tags to all 3 errors - [FIXED] A03-6: (LOW) `DuplicateLHSItem` is the only error in ErrParse.sol using `@dev` prefix, inconsistent with all other errors in the file — removed `@dev` -- [PENDING] A04-1: (LOW) `LibOpCall` is missing `referenceFn` unlike all other opcode libraries +- [DISMISSED] A04-1: (LOW) `LibOpCall` is missing `referenceFn` — call opcode cannot have a meaningful referenceFn, it would duplicate run - [FIXED] A04-2: (LOW) Unused `using LibPointer for Pointer` declaration and import in LibOpCall — removed using directive and LibPointer import -- [PENDING] A05-1: (LOW) Magic numbers throughout `evalLoop` assembly shared with LibIntegrityCheck should be named constants +- [DISMISSED] A05-1: (LOW) Magic numbers throughout `evalLoop` assembly shared with LibIntegrityCheck should be named constants — EVM word-size intrinsics and unrolled loop byte positions; named constants would make the loop harder to verify visually; existing comments adequate - [FIXED] A05-2: (LOW) Stale reference to variable name `tail` instead of `stack` in `eval2` NatSpec comment in LibEval — corrected to `stack` -- [PENDING] A06-1: (LOW) Inconsistent constant sourcing for context ops; LibExternOpContextRainlen defines inline constants while siblings import from LibContext.sol -- [PENDING] A06-2: (LOW) Inconsistent function mutability across subParser functions; LibExternOpIntInc is `view` while others are `pure` -- [PENDING] A06-3: (LOW) Magic number in LibExternOpIntInc.run for decimal float value 1 should use named constant -- [PENDING] A06-4: (LOW) Magic number 78 in LibParseLiteralRepeat bound check should use named constant -- [PENDING] A12-1: (LOW) Magic number `0x18` for cursor alignment in `integrityCheck2` lacks derivation explanation +- [DISMISSED] A06-1: (LOW) Inconsistent constant sourcing for context ops — rainlen constants are application-specific (different context column), local definition is appropriate +- [DISMISSED] A06-2: (LOW) Inconsistent function mutability across subParser functions — mutability difference structurally required, address(this) requires view +- [FIXED] A06-3: (LOW) Magic number in LibExternOpIntInc.run for decimal float value 1 should use named constant — hoisted packLossless(1e37, -37) out of loop into local variable with comment +- [FIXED] A06-4: (LOW) Magic number 78 in LibParseLiteralRepeat bound check should use named constant — added MAX_REPEAT_LITERAL_LENGTH constant +- [DISMISSED] A12-1: (LOW) Magic number `0x18` for cursor alignment — single occurrence with adequate comment, named constant would not clarify derivation - [FIXED] A14-1: (LOW) Unused variable `success` from `staticcall` in `stackTrace` assembly should use `pop()` idiom in LibInterpreterState — changed to `pop(staticcall(...))` - [FIXED] A14-2: (LOW) Incorrect arithmetic in `stackTrace` NatSpec gas cost analysis in LibInterpreterState — fixed division denominator and included memory term -- [PENDING] A16-1: (LOW) Inconsistent `referenceFn` return pattern (new array vs mutate-in-place) across bitwise ops; LibOpDecodeBits is 1-input/1-output but allocates new array unlike other 1-in/1-out ops +- [DISMISSED] A16-1: (LOW) Inconsistent `referenceFn` return pattern (new array vs mutate-in-place) across bitwise ops; LibOpDecodeBits is 1-input/1-output but allocates new array unlike other 1-in/1-out ops — false positive; referenceFn already mutates inputs[0] in place and returns inputs - [FIXED] A16-2: (LOW) Inconsistent `uint256` cast on `type(uint8).max` between LibOpShiftBitsLeft and LibOpShiftBitsRight — removed unnecessary cast from ShiftBitsLeft - [FIXED] A16-3: (LOW) Inconsistent lint suppression comments between LibOpDecodeBits and LibOpEncodeBits for identical shift operation — added slither suppression to EncodeBits to match DecodeBits -- [PENDING] A16-4: (LOW) Repeated operand parsing logic for `startBit` and `length` duplicated 6 times across LibOpDecodeBits and LibOpEncodeBits +- [DISMISSED] A16-4: (LOW) Repeated operand parsing logic for `startBit` and `length` duplicated 6 times — inline for gas in run, consistent pattern across integrity/referenceFn - [FIXED] A20-1: (LOW) `@title` NatSpec mismatch in `LibOpUint256ERC20BalanceOf.sol` missing `Lib` prefix — corrected to LibOpUint256ERC20BalanceOf - [FIXED] A20-2: (LOW) Inconsistent `forge-lint` comment formatting in `LibOpUint256ERC20TotalSupply.sol` (space after `//` vs no space) — removed space to match other files - [FIXED] A22-1: (LOW) `@title` NatSpec missing `Lib` prefix in `LibOpUint256ERC721BalanceOf` — corrected to LibOpUint256ERC721BalanceOf @@ -756,62 +756,62 @@ Tracks the disposition of every LOW+ finding from pass4 audit reports (code qual - [FIXED] A25a-2: (LOW) Missing "point" in LibOpHeadroom run NatSpec ("decimal floating headroom" should be "decimal floating point headroom") — fixed in earlier commit - [FIXED] A25a-3: (LOW) Missing "decimal" in LibOpInv run NatSpec (says "floating point" instead of "decimal floating point") — fixed as part of @notice removal - [FIXED] A25a-4: (LOW) Misleading `unchecked` block with overflow comment in LibOpMax.referenceFn irrelevant to `max` operation — removed unnecessary unchecked block and comment -- [PENDING] A28-1: (LOW) Unnecessary `unchecked` block wrapping entire `run` body in LibOpSet has no semantic effect on assembly-only arithmetic +- [FIXED] A28-1: (LOW) Unnecessary `unchecked` block wrapping entire `run` body in LibOpSet — already removed in prior commit - [FIXED] A29-1: (LOW) Misleading comment in `referenceFn` for LibOpUint256Div and LibOpUint256Sub says "overflow" but Div reverts on divide-by-zero and Sub reverts on underflow — corrected both - [FIXED] A29-2: (LOW) Inconsistent NatSpec description in LibOpLinearGrowth references wrong variable names ("a" and "r" instead of "base" and "rate") — corrected to "base" and "rate", also removed `@notice` - [DISMISSED] A30-1: (MEDIUM) Dead constants `NOT_LOW_16_BIT_MASK` and `ACTIVE_SOURCE_MASK` defined but never referenced anywhere in the codebase — false positive; neither constant exists in the codebase - [DISMISSED] A30-2: (LOW) Potentially unused `using LibBytes32Array` declaration in LibParse.sol — false positive; used for `.startPointer()` on operandValues -- [PENDING] A30-3: (LOW) Magic numbers in paren tracking logic (group size 3, reserved bytes 2, max offset 59, shift 0xf0) -- [PENDING] A30-4: (LOW) `parseRHS` function length (~210 lines) makes it harder to review and audit +- [DISMISSED] A30-3: (LOW) Magic numbers in paren tracking logic — compact performance-sensitive assembly, inline comments explain values +- [DISMISSED] A30-4: (LOW) `parseRHS` function length (~210 lines) makes it harder to review and audit — refactoring suggestion only; hot-path parser function, extraction adds gas overhead with no correctness benefit - [FIXED] A33-1: (LOW) Unused `using` directives (`LibParseInterstitial`, `LibSubParse`) and corresponding unused imports in LibParseLiteral.sol — removed both using directives and imports -- [PENDING] A33-2: (MEDIUM) Function pointer mutability mismatch: `selectLiteralParserByIndex` returns `pure` typed pointer but literal parsers array stores `view` typed pointers, bypassing Solidity mutability checking via raw assembly -- [PENDING] A33-3: (LOW) Parameter naming inconsistency: `parseDecimalFloatPacked` uses `start` instead of `cursor` unlike all other parse functions -- [PENDING] A33-4: (LOW) Unnamed `ParseState memory` parameter in `boundHex` inconsistent with named `state` parameter in `boundString` -- [PENDING] A33-5: (LOW) Magic number `0x40` in hex overflow check represents max hex literal length (64 nybbles) without named constant -- [PENDING] A33-6: (LOW) Inconsistent `unchecked` block usage across parse functions: some wrap entire body, others do not use it at all -- [PENDING] A39-1: (LOW) Magic numbers in LibParseStackName linked-list encoding without named constants -- [PENDING] A39-2: (LOW) Magic number `0xf0` in comment sequence parsing in LibParseInterstitial -- [PENDING] A39-3: (LOW) Duplicated Float-to-uint conversion pattern across five operand handlers in LibParseOperand -- [PENDING] A39-4: (LOW) Tight coupling between LibParseStackName and ParseState internal layout via direct `topLevel1` access -- [PENDING] A39-5: (LOW) Different fingerprint representations in `pushStackName` vs `stackNameIndex` is confusing +- [DISMISSED] A33-2: (MEDIUM) Function pointer mutability mismatch — re-reading source shows return type is already view, finding is stale +- [DISMISSED] A33-3: (LOW) Parameter naming inconsistency — `start` avoids collision with local variable `cursor` in parseDecimalFloatInline +- [DISMISSED] A33-4: (LOW) Unnamed `ParseState memory` parameter — correct Solidity idiom for unused compatibility parameter +- [DISMISSED] A33-5: (LOW) Magic number `0x40` in hex overflow check — 64 hex chars = 32 bytes is well-known EVM convention +- [DISMISSED] A33-6: (LOW) Inconsistent `unchecked` block usage — applied based on arithmetic profile of each function, forcing uniformity would be misleading +- [DISMISSED] A39-1: (LOW) Magic numbers in LibParseStackName — small file with documented bit layout, standard masks are recognizable +- [DISMISSED] A39-2: (LOW) Magic number `0xf0` in comment sequence parsing — codebase-wide convention for 2-byte extraction +- [DISMISSED] A39-3: (LOW) Duplicated Float-to-uint conversion pattern — handlers have subtly different needs, helper would only cover simple cases +- [DISMISSED] A39-4: (LOW) Tight coupling between LibParseStackName and ParseState — accessors add overhead in hot path, coupling inherent to parser design +- [DISMISSED] A39-5: (LOW) Different fingerprint representations — two representations minimize instructions in both paths, gas trade-off justified - [FIXED] A43-1: (LOW) Incorrect inline comments in `newState` constructor misaligned with struct field order — corrected "literalBloom" to "constantsBuilder" and "constantsBuilder" to "constantsBloom" - [FIXED] A43-2: (LOW) Stale function name `newActiveSource` in comment should be `newActiveSourcePointer` — corrected to `resetSource` which is the actual caller - [DISMISSED] A43-3: (MEDIUM) FSM NatSpec does not match defined constants (bit positions shifted, missing/extra fields) — false positive; NatSpec bits 0-3 match FSM_YANG_MASK(1), FSM_WORD_END_MASK(1<<1), FSM_ACCEPTING_INPUTS_MASK(1<<2), FSM_ACTIVE_SOURCE_MASK(1<<3) exactly -- [PENDING] A43-4: (LOW) Magic number `0x3f` in `highwater` should be a named constant -- [PENDING] A45-1: (LOW) Constructor lacks NatSpec documentation in Rainterpreter.sol +- [DOCUMENTED] A43-4: (LOW) Magic number `0x3f` in `highwater` — added comment explaining the derivation +- [DISMISSED] A45-1: (LOW) Constructor lacks NatSpec in Rainterpreter.sol — false positive, constructor already has NatSpec - [FIXED] A45-2: (LOW) NatSpec triple-slash used for inline code comment inside RainterpreterStore.set function body — changed `///` to `//` -- [PENDING] A45-3: (LOW) `type(uint256).max` used as `maxOutputs` parameter without named constant in Rainterpreter.eval4 -- [PENDING] A45-4: (LOW) `buildOperandHandlerFunctionPointers` and `buildLiteralParserFunctionPointers` missing `override` keyword in RainterpreterParser, inconsistent with Rainterpreter +- [DISMISSED] A45-3: (LOW) `type(uint256).max` without named constant — universally recognized Solidity idiom +- [FIXED] A45-4: (LOW) `buildOperandHandlerFunctionPointers` and `buildLiteralParserFunctionPointers` missing `override` keyword in RainterpreterParser — already fixed, both functions have `override` - [FIXED] A47-1: (LOW) `@inheritdoc IERC165` inconsistent with other concrete contracts that use `@inheritdoc ERC165` in RainterpreterExpressionDeployer — changed to `@inheritdoc ERC165` - [FIXED] A47-2: (LOW) Redundant NatSpec before `@inheritdoc` on `buildIntegrityFunctionPointers` is dead documentation in RainterpreterExpressionDeployer — removed dead NatSpec -- [PENDING] A47-3: (LOW) RainterpreterDISPaiRegistry does not implement ERC165 unlike all other concrete contracts -- [PENDING] A49-1: (LOW) Error `InvalidRepeatCount` defined inline instead of in `src/error/` directory per codebase convention +- [FIXED] A47-3: (LOW) RainterpreterDISPaiRegistry does not implement ERC165 — added ERC165 with IDISPaiRegistry interface support and test +- [DISMISSED] A49-1: (LOW) Error `InvalidRepeatCount` defined inline — specific to reference extern, one-error file is over-engineering - [FIXED] A49-2: (LOW) Variable named `float` shadows its type name `Float` differing only in case — renamed to `repeatCount` -- [PENDING] A49-3: (LOW) `matchSubParseLiteralDispatch` narrows from `view` to `pure virtual override` constraining future subclass override chain +- [DISMISSED] A49-3: (LOW) `matchSubParseLiteralDispatch` narrows view to pure — correct Solidity override semantics, intentional constraint - [DISMISSED] R01-1: (HIGH) Duplicate short flag `-i` on both `fork_url` and `fork_block_number` in fork.rs causes clap runtime panic — false positive; no collision - [DISMISSED] R01-2: (MEDIUM) Unused dependencies `serde` and `serde_bytes` in CLI crate Cargo.toml — false positive; neither dependency exists in CLI Cargo.toml -- [PENDING] R01-3: (LOW) Incorrect `homepage` URL in CLI Cargo.toml points to `rain.orderbook` instead of `rain.interpreter` -- [PENDING] R01-4: (LOW) Inconsistent error handling pattern between eval.rs and parse.rs wraps errors with `anyhow!` losing original error chain -- [PENDING] R01-5: (LOW) Eval output uses Debug formatting `{:#?}` labeled as Binary encoding, inconsistent with Parse subcommand -- [PENDING] R01-6: (LOW) `Execute` trait uses native async fn in trait producing non-Send futures limiting future flexibility +- [FIXED] R01-3: (LOW) Incorrect `homepage` URL in CLI Cargo.toml points to `rain.orderbook` instead of `rain.interpreter` — fixed URL and switched to workspace reference +- [FIXED] R01-4: (LOW) Inconsistent error handling pattern between eval.rs and parse.rs wraps errors with `anyhow!` losing original error chain — replaced `anyhow!("{:?}", e)` with `anyhow!(e)` to preserve error chain +- [DISMISSED] R01-5: (LOW) Eval output uses Debug formatting — CLI UX issue, proper fix requires JSON serialization feature work +- [DISMISSED] R01-6: (LOW) `Execute` trait non-Send futures — execute() called directly in main, not spawned - [DISMISSED] R02-1: (MEDIUM) `unwrap()` on `traces` in `From>` for `RainEvalResult` will panic if traces are None — false positive; impl is TryFrom with .ok_or(MissingTraces)?, not From with unwrap -- [PENDING] R02-2: (LOW) Redundant `.to_owned()`, `.deref()`, `.clone()` chain in trace extraction creates multiple unnecessary copies -- [PENDING] R02-3: (LOW) Inconsistent trace ordering approach between `From` and `TryFrom` implementations +- [FIXED] R02-2: (LOW) Redundant `.to_owned()`, `.deref()`, `.clone()` chain in trace extraction creates multiple unnecessary copies — simplified to `.nodes().iter()`, removed unused `Deref` import +- [FIXED] R02-3: (LOW) Inconsistent trace ordering approach between `From` and `TryFrom` implementations — both now use `.rev()` on iterator - [DISMISSED] R02-4: (MEDIUM) `search_trace_by_path` has logic bug in parent tracking — false positive; code correctly does current_parent_index = current_source_index at line 180 -- [PENDING] R02-5: (LOW) `CreateNamespace` is an empty struct used only as function namespace; should be a free function -- [PENDING] R02-6: (LOW) Typo "commiting" should be "committing" in doc comments for `alloy_call` and `call` -- [PENDING] R02-7: (LOW) `#[allow(clippy::for_kv_map)]` suppresses valid lint; should use `.values()` instead -- [PENDING] R02-8: (LOW) `add_or_select` uses `unwrap()` on `fork_evm_env` where `new_with_fork` uses `?` for same call -- [PENDING] R02-9: (LOW) `TryFrom` for `RainEvalResult` always produces empty `stack` and `writes` without documenting this limitation -- [PENDING] R02-10: (LOW) `#[derive]` placed before doc comments in `ForkEvalArgs` and `ForkParseArgs` is unconventional -- [PENDING] R02-11: (LOW) `roll_fork` uses `unwrap()` after `is_none()` check instead of idiomatic `if let Some` -- [PENDING] R03-1: (LOW) Unused dependencies `serde` and `serde_json` in parser crate Cargo.toml -- [PENDING] R03-2: (LOW) Unused dependency `serde_json` in test_fixtures crate Cargo.toml +- [FIXED] R02-5: (LOW) `CreateNamespace` is an empty struct used only as function namespace; should be a free function — converted to free function `qualify_namespace`, updated call sites +- [FIXED] R02-6: (LOW) Typo "commiting" should be "committing" in doc comments for `alloy_call` and `call` — corrected both occurrences +- [FIXED] R02-7: (LOW) `#[allow(clippy::for_kv_map)]` suppresses valid lint; should use `.values()` instead — replaced with `.values()` iteration +- [FIXED] R02-8: (LOW) `add_or_select` uses `unwrap()` on `fork_evm_env` where `new_with_fork` uses `?` for same call — replaced with `?` +- [FIXED] R02-9: (LOW) `TryFrom` for `RainEvalResult` always produces empty `stack` and `writes` without documenting this limitation — added doc comment explaining why +- [FIXED] R02-10: (LOW) `#[derive]` placed before doc comments in `ForkEvalArgs` and `ForkParseArgs` is unconventional — moved doc comments before `#[derive]` +- [FIXED] R02-11: (LOW) `roll_fork` uses `unwrap()` after `is_none()` check instead of idiomatic `if let Some` — replaced with `.ok_or()?.unwrap_or()` +- [FIXED] R03-1: (LOW) Unused dependencies `serde` and `serde_json` in parser crate Cargo.toml — removed both +- [FIXED] R03-2: (LOW) Unused dependency `serde_json` in test_fixtures crate Cargo.toml — removed - [DISMISSED] R03-3: (MEDIUM) Edition inconsistency — false positive; parser and dispair both hardcode edition = "2024" matching workspace, not 2021 -- [PENDING] R03-4: (LOW) Homepage URL inconsistency — parser and dispair use `rainlanguage` org instead of workspace `rainprotocol` -- [PENDING] R03-5: (MEDIUM) Duplicated `Parser2` trait definition for wasm vs non-wasm targets violates DRY -- [PENDING] R03-6: (LOW) `DISPaiR` doc comment mentions "Registry" but struct has no registry field -- [PENDING] R03-7: (LOW) Excessive `unwrap()` in `LocalEvm::new()` and `deploy_new_token()` produces unhelpful panic messages +- [FIXED] R03-4: (LOW) Homepage URL inconsistency — workspace had wrong org `rainprotocol`, corrected to `rainlanguage`; parser/dispair/cli now use `homepage.workspace = true` +- [DISMISSED] R03-5: (MEDIUM) Duplicated `Parser2` trait — intentional Send bound difference between targets, duplication clearer than macro alternatives +- [FIXED] R03-6: (LOW) `DISPaiR` doc comment mentions "Registry" but struct has no registry field — removed "Registry" from doc comment +- [DISMISSED] R03-7: (LOW) Excessive `unwrap()` in test fixtures — standard test-code practice, stack traces sufficient - [DISMISSED] R03-8: (MEDIUM) `parse_pragma_text` is an inherent method while `parse_text` is a trait method creating asymmetry — false positive; both are trait methods with default implementations -- [PENDING] R03-9: (LOW) `DISPaiR` struct lacks `Debug` derive which is unusual for data-carrying struct -- [PENDING] R03-10: (LOW) Cargo.toml metadata inconsistency — parser and dispair hardcode license instead of using workspace +- [FIXED] R03-9: (LOW) `DISPaiR` struct lacks `Debug` derive which is unusual for data-carrying struct — added `Debug` derive +- [FIXED] R03-10: (LOW) Cargo.toml metadata inconsistency — parser and dispair hardcode license instead of using workspace — switched to `edition.workspace`, `license.workspace`, `homepage.workspace` diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2a52f3cfb..b033b2efa 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "rain-i9r-cli" version = "0.0.1" -edition = "2021" -license = "CAL-1.0" +edition.workspace = true +license.workspace = true description = "Rain Interpreter CLI." -homepage = "https://github.com/rainprotocol/rain.orderbook" +homepage.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/crates/cli/src/commands/eval.rs b/crates/cli/src/commands/eval.rs index c05b12de5..b277f6c8e 100644 --- a/crates/cli/src/commands/eval.rs +++ b/crates/cli/src/commands/eval.rs @@ -2,9 +2,9 @@ use crate::execute::Execute; use crate::fork::NewForkedEvmCliArgs; use crate::output::SupportedOutputEncoding; use alloy::primitives::{Address, U256}; -use anyhow::anyhow; use anyhow::Context; use anyhow::Result; +use anyhow::anyhow; use clap::Args; use rain_interpreter_bindings::IInterpreterStoreV3::FullyQualifiedNamespace; use rain_interpreter_eval::trace::RainEvalResult; @@ -123,15 +123,18 @@ impl Execute for Eval { match result { Ok(res) => { - let rain_eval_result: RainEvalResult = - res.try_into().map_err(|e| anyhow!("{:?}", e))?; + let rain_eval_result: RainEvalResult = res.try_into().map_err( + |e: rain_interpreter_eval::trace::RainEvalResultFromRawCallResultError| { + anyhow!(e) + }, + )?; crate::output::output( &self.output_path, SupportedOutputEncoding::Binary, format!("{:#?}", rain_eval_result).as_bytes(), ) } - Err(e) => Err(anyhow!("Error: {:?}", e)), + Err(e) => Err(anyhow!(e)), } } } diff --git a/crates/cli/src/commands/parse.rs b/crates/cli/src/commands/parse.rs index f22e2517a..b18ff7077 100644 --- a/crates/cli/src/commands/parse.rs +++ b/crates/cli/src/commands/parse.rs @@ -2,8 +2,8 @@ use crate::execute::Execute; use crate::fork::NewForkedEvmCliArgs; use crate::output::SupportedOutputEncoding; use alloy::primitives::Address; -use anyhow::anyhow; use anyhow::Result; +use anyhow::anyhow; use clap::Args; use rain_interpreter_eval::eval::ForkParseArgs; use rain_interpreter_eval::fork::Forker; @@ -58,7 +58,7 @@ impl Execute for Parse { self.output_encoding.clone(), res.raw.result.to_owned().to_vec().as_slice(), ), - Err(e) => Err(anyhow!("Error: {:?}", e)), + Err(e) => Err(anyhow!(e)), } } } diff --git a/crates/dispair/Cargo.toml b/crates/dispair/Cargo.toml index fcae93c53..f5fe94ac4 100644 --- a/crates/dispair/Cargo.toml +++ b/crates/dispair/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "rain_interpreter_dispair" version = "0.1.0" -edition = "2024" -license = "CAL-1.0" +edition.workspace = true +license.workspace = true description = "Rain Interpreter Rust Crate." -homepage = "https://github.com/rainlanguage/rain.interpreter" +homepage.workspace = true [dependencies] alloy = { workspace = true } diff --git a/crates/dispair/src/lib.rs b/crates/dispair/src/lib.rs index 68c8192e2..4735f93f7 100644 --- a/crates/dispair/src/lib.rs +++ b/crates/dispair/src/lib.rs @@ -1,8 +1,8 @@ use alloy::primitives::*; /// DISPaiR -/// Struct representing Deployer/Interpreter/Store/Parser/Registry instances. -#[derive(Clone, Default)] +/// Struct representing Deployer/Interpreter/Store/Parser instances. +#[derive(Debug, Clone, Default)] pub struct DISPaiR { pub deployer: Address, pub interpreter: Address, diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index 9b850bbf9..8cfa1376c 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -5,8 +5,8 @@ use rain_interpreter_bindings::IInterpreterStoreV3::FullyQualifiedNamespace; use rain_interpreter_bindings::IInterpreterV4::{EvalV4, eval4Call}; use rain_interpreter_bindings::IParserV2::parse2Call; -#[derive(Debug, Clone)] /// Arguments for evaluating a Rainlang string in a forked EVM context +#[derive(Debug, Clone)] pub struct ForkEvalArgs { /// The Rainalang string to evaluate pub rainlang_string: String, @@ -30,8 +30,8 @@ pub struct ForkEvalArgs { pub state_overlay: Vec, } -#[derive(Debug, Clone)] /// Arguments for parsing a Rainlang string in a forked EVM context +#[derive(Debug, Clone)] pub struct ForkParseArgs { /// The Rainlang string to parse pub rainlang_string: String, diff --git a/crates/eval/src/fork.rs b/crates/eval/src/fork.rs index c17456ccb..267099565 100644 --- a/crates/eval/src/fork.rs +++ b/crates/eval/src/fork.rs @@ -192,7 +192,7 @@ impl Forker { let create_fork = CreateFork { url: fork_url.to_string(), enable_caching: true, - env: evm_opts.fork_evm_env(&fork_url).await.unwrap().0, + env: evm_opts.fork_evm_env(&fork_url).await?.0, evm_opts, }; let block_number = if let Some(block_number) = fork_block_number { @@ -222,7 +222,7 @@ impl Forker { } } - /// Calls the forked EVM without commiting to state using alloy typed arguments. + /// Calls the forked EVM without committing to state using alloy typed arguments. /// # Arguments /// * `from_address` - The address to call from. /// * `to_address` - The address to call to. @@ -304,7 +304,7 @@ impl Forker { Ok(ForkTypedReturn { raw, typed_return }) } - /// Calls the forked EVM without commiting to state. + /// Calls the forked EVM without committing to state. /// # Arguments /// * `from_address` - The address to call from. /// * `to_address` - The address to call to. @@ -381,18 +381,16 @@ impl Forker { .ok_or(ForkCallError::ExecutorError("no active fork!".to_owned()))?; let mut org_block_number = None; let mut spec_id = SpecId::default(); - #[allow(clippy::for_kv_map)] - for (_fork_id, (local_id, sid, bnumber)) in &self.forks { + for (local_id, sid, bnumber) in self.forks.values() { if *local_id == active_fork_local_id { spec_id = *sid; org_block_number = Some(*bnumber); break; } } - if org_block_number.is_none() { - return Err(ForkCallError::ExecutorError("no active fork!".to_owned())); - } - let block_number = block_number.unwrap_or(org_block_number.unwrap()); + let org_block_number = + org_block_number.ok_or(ForkCallError::ExecutorError("no active fork!".to_owned()))?; + let block_number = block_number.unwrap_or(org_block_number); self.executor.env_mut().evm_env.block_env.number = block_number; @@ -511,7 +509,7 @@ impl Forker { #[cfg(test)] mod tests { use super::*; - use crate::namespace::CreateNamespace; + use crate::namespace::qualify_namespace; use alloy::eips::BlockNumberOrTag; use alloy::sol; use alloy::{ @@ -588,8 +586,7 @@ mod tests { .await .unwrap(); - let fully_quallified_namespace = - CreateNamespace::qualify_namespace(namespace.into(), from_address); + let fully_quallified_namespace = qualify_namespace(namespace.into(), from_address); let get = forker .alloy_call( diff --git a/crates/eval/src/namespace.rs b/crates/eval/src/namespace.rs index becc72cf8..aa2e12879 100644 --- a/crates/eval/src/namespace.rs +++ b/crates/eval/src/namespace.rs @@ -1,20 +1,18 @@ use alloy::primitives::{Address, B256, U256, keccak256}; use rain_interpreter_bindings::IInterpreterV4::FullyQualifiedNamespace; -pub struct CreateNamespace {} - -impl CreateNamespace { - pub fn qualify_namespace(state_namespace: B256, sender: Address) -> FullyQualifiedNamespace { - // Combine state namespace and sender into a single 64-byte array - let mut combined = [0u8; 64]; - combined[..32].copy_from_slice(state_namespace.as_slice()); - combined[44..].copy_from_slice(sender.as_slice()); - - // Hash the combined array with Keccak256 - let qualified_namespace = keccak256(combined); - - FullyQualifiedNamespace::from(U256::from_be_bytes(qualified_namespace.0)) - } +/// Qualifies a state namespace by hashing it with the sender address, +/// matching the on-chain `qualifyNamespace` logic in RainterpreterStore. +pub fn qualify_namespace(state_namespace: B256, sender: Address) -> FullyQualifiedNamespace { + // Combine state namespace and sender into a single 64-byte array + let mut combined = [0u8; 64]; + combined[..32].copy_from_slice(state_namespace.as_slice()); + combined[44..].copy_from_slice(sender.as_slice()); + + // Hash the combined array with Keccak256 + let qualified_namespace = keccak256(combined); + + FullyQualifiedNamespace::from(U256::from_be_bytes(qualified_namespace.0)) } #[cfg(test)] @@ -26,7 +24,7 @@ mod tests { fn test_new() { let state_namespace = B256::repeat_byte(0x1); let sender = Address::repeat_byte(0x2); - let namespace = CreateNamespace::qualify_namespace(state_namespace, sender); + let namespace = qualify_namespace(state_namespace, sender); // Got the below from chisel let expected = diff --git a/crates/eval/src/trace.rs b/crates/eval/src/trace.rs index 65b92e378..f8f5053de 100644 --- a/crates/eval/src/trace.rs +++ b/crates/eval/src/trace.rs @@ -7,8 +7,6 @@ use foundry_evm::executors::RawCallResult; use rain_interpreter_bindings::IInterpreterV4::{eval4Call, eval4Return}; use revm::primitives::address; use serde::{Deserialize, Serialize}; -#[cfg(not(target_family = "wasm"))] -use std::ops::Deref; use thiserror::Error; #[cfg(target_family = "wasm")] use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; @@ -76,12 +74,9 @@ impl TryFrom> for RainEvalResult { let call_trace_arena = typed_return .raw .traces - .ok_or(RainEvalResultFromRawCallResultError::MissingTraces)? - .to_owned(); - let mut traces: Vec = call_trace_arena - .deref() - .clone() - .into_nodes() + .ok_or(RainEvalResultFromRawCallResultError::MissingTraces)?; + let traces: Vec = call_trace_arena + .nodes() .iter() .filter_map(|trace_node| { if Address::from(trace_node.trace.address.into_array()) == RAIN_TRACER_ADDRESS { @@ -90,8 +85,8 @@ impl TryFrom> for RainEvalResult { None } }) + .rev() .collect(); - traces.reverse(); Ok(RainEvalResult { reverted: typed_return.raw.reverted, @@ -108,6 +103,8 @@ pub enum RainEvalResultFromRawCallResultError { MissingTraces, } +/// Note: `RawCallResult` does not contain ABI-decoded stack/writes, so these +/// fields are left empty. Only traces are populated from the call trace arena. #[cfg(not(target_family = "wasm"))] impl TryFrom for RainEvalResult { type Error = RainEvalResultFromRawCallResultError; diff --git a/crates/parser/Cargo.toml b/crates/parser/Cargo.toml index ca73282d7..3b8ab105a 100644 --- a/crates/parser/Cargo.toml +++ b/crates/parser/Cargo.toml @@ -1,17 +1,15 @@ [package] name = "rain_interpreter_parser" version = "0.1.0" -edition = "2024" -license = "CAL-1.0" +edition.workspace = true +license.workspace = true description = "Rain Interpreter Parser Rust Crate." -homepage = "https://github.com/rainlanguage/rain.interpreter" +homepage.workspace = true [dependencies] alloy-ethers-typecast = { workspace = true } rain_interpreter_dispair = { workspace = true } rain_interpreter_bindings = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } alloy = { workspace = true } thiserror = { workspace = true } diff --git a/crates/test_fixtures/Cargo.toml b/crates/test_fixtures/Cargo.toml index 63a8844a3..7c0e3633b 100644 --- a/crates/test_fixtures/Cargo.toml +++ b/crates/test_fixtures/Cargo.toml @@ -8,7 +8,6 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -serde_json = { workspace = true } alloy = { workspace = true, features = ["node-bindings", "sol-types", "rpc-types", "provider-http", "network", "contract", "signer-local"] } [target.'cfg(target_family = "wasm")'.dependencies] diff --git a/src/abstract/BaseRainterpreterSubParser.sol b/src/abstract/BaseRainterpreterSubParser.sol index 4add0300f..ecaac9f4b 100644 --- a/src/abstract/BaseRainterpreterSubParser.sol +++ b/src/abstract/BaseRainterpreterSubParser.sol @@ -16,6 +16,7 @@ import {LibParseOperand} from "../lib/parse/LibParseOperand.sol"; import {IDescribedByMetaV1} from "rain.metadata/interface/IDescribedByMetaV1.sol"; import {IParserToolingV1} from "rain.sol.codegen/interface/IParserToolingV1.sol"; import {ISubParserToolingV1} from "rain.sol.codegen/interface/ISubParserToolingV1.sol"; +import {SubParserIndexOutOfBounds} from "../error/ErrSubParse.sol"; /// @dev This is a placeholder for the subparser function pointers. /// The subparser function pointers are a list of 16 bit function pointers, @@ -38,12 +39,6 @@ bytes constant SUB_PARSER_OPERAND_HANDLERS = hex""; /// parsers. bytes constant SUB_PARSER_LITERAL_PARSERS = hex""; -/// @dev Thrown when a sub parser dispatch index is out of bounds for the -/// function pointer table. -/// @param index The out-of-bounds index. -/// @param length The number of function pointers available. -error SubParserIndexOutOfBounds(uint256 index, uint256 length); - /// Base implementation of `ISubParserV4`. Inherit from this contract and /// override the virtual functions to align all the relevant pointers and /// metadata bytes so that it can actually run. diff --git a/src/concrete/RainterpreterDISPaiRegistry.sol b/src/concrete/RainterpreterDISPaiRegistry.sol index e68ce661d..df44b8d7d 100644 --- a/src/concrete/RainterpreterDISPaiRegistry.sol +++ b/src/concrete/RainterpreterDISPaiRegistry.sol @@ -4,6 +4,7 @@ pragma solidity =0.8.25; import {LibInterpreterDeploy} from "../lib/deploy/LibInterpreterDeploy.sol"; import {IDISPaiRegistry} from "../interface/IDISPaiRegistry.sol"; +import {ERC165} from "openzeppelin-contracts/contracts/utils/introspection/ERC165.sol"; /// @title RainterpreterDISPaiRegistry /// @notice DISPaiR registry contract that exposes the deterministic Zoltu deploy @@ -11,7 +12,12 @@ import {IDISPaiRegistry} from "../interface/IDISPaiRegistry.sol"; /// Store, and Parser. Deployed via the same Zoltu pattern so that external /// tooling can discover all component addresses from a single known registry /// address. -contract RainterpreterDISPaiRegistry is IDISPaiRegistry { +contract RainterpreterDISPaiRegistry is IDISPaiRegistry, ERC165 { + /// @inheritdoc ERC165 + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return interfaceId == type(IDISPaiRegistry).interfaceId || super.supportsInterface(interfaceId); + } + /// @inheritdoc IDISPaiRegistry function expressionDeployerAddress() external pure override returns (address) { return LibInterpreterDeploy.EXPRESSION_DEPLOYER_DEPLOYED_ADDRESS; diff --git a/src/concrete/RainterpreterParser.sol b/src/concrete/RainterpreterParser.sol index 13e7be084..bfac1a32e 100644 --- a/src/concrete/RainterpreterParser.sol +++ b/src/concrete/RainterpreterParser.sol @@ -104,12 +104,12 @@ contract RainterpreterParser is ERC165, IParserToolingV1 { } /// External function to build the operand handler function pointers. - function buildOperandHandlerFunctionPointers() external pure returns (bytes memory) { + function buildOperandHandlerFunctionPointers() external pure override returns (bytes memory) { return LibAllStandardOps.operandHandlerFunctionPointers(); } /// External function to build the literal parser function pointers. - function buildLiteralParserFunctionPointers() external pure returns (bytes memory) { + function buildLiteralParserFunctionPointers() external pure override returns (bytes memory) { return LibAllStandardOps.literalParserFunctionPointers(); } } diff --git a/src/error/ErrSubParse.sol b/src/error/ErrSubParse.sol index e5f7dc3ea..8ee0995fb 100644 --- a/src/error/ErrSubParse.sol +++ b/src/error/ErrSubParse.sol @@ -19,3 +19,9 @@ error ConstantOpcodeConstantsHeightOverflow(uint256 constantsHeight); /// @param column The column value that overflowed. /// @param row The row value that overflowed. error ContextGridOverflow(uint256 column, uint256 row); + +/// @notice Thrown when a sub parser dispatch index is out of bounds for the +/// function pointer table. +/// @param index The out-of-bounds index. +/// @param length The number of function pointers available. +error SubParserIndexOutOfBounds(uint256 index, uint256 length); diff --git a/src/generated/RainterpreterReferenceExtern.pointers.sol b/src/generated/RainterpreterReferenceExtern.pointers.sol index 379c679ac..34de2408d 100644 --- a/src/generated/RainterpreterReferenceExtern.pointers.sol +++ b/src/generated/RainterpreterReferenceExtern.pointers.sol @@ -10,7 +10,7 @@ pragma solidity ^0.8.25; // file needs the contract to exist so that it can be compiled. /// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0x9161638aadaa8ebe2a0795b8aac1c8cef0fabfa86f365c52ddf0fe0b780c985a); +bytes32 constant BYTECODE_HASH = bytes32(0x13127daf097982018a78140bc5b64e436e2be46e54441d680f53b73d2e03d7fe); /// @dev The hash of the meta that describes the contract. bytes32 constant DESCRIBED_BY_META_HASH = bytes32(0xadf71693c6ecf3fd560904bc46973d1b6e651440d15366673f9b3984749e7c16); @@ -48,17 +48,17 @@ bytes constant SUB_PARSER_WORD_PARSERS = hex"079f07bf07cd07db07ea"; /// @dev Every two bytes is a function pointer for an operand handler. /// These positional indexes all map to the same indexes looked up in the parse /// meta. -bytes constant OPERAND_HANDLER_FUNCTION_POINTERS = hex"0dda0e1c0dda0dda0dda"; +bytes constant OPERAND_HANDLER_FUNCTION_POINTERS = hex"0de70e290de70de70de7"; /// @dev Every two bytes is a function pointer for a literal parser. /// Literal dispatches are determined by the first byte(s) of the literal /// rather than a full word lookup, and are done with simple conditional /// jumps as the possibilities are limited compared to the number of words we /// have. -bytes constant LITERAL_PARSER_FUNCTION_POINTERS = hex"0d31"; +bytes constant LITERAL_PARSER_FUNCTION_POINTERS = hex"0d3e"; /// @dev The function pointers for the integrity check fns. -bytes constant INTEGRITY_FUNCTION_POINTERS = hex"0bb8"; +bytes constant INTEGRITY_FUNCTION_POINTERS = hex"0bc5"; /// @dev The function pointers known to the interpreter for dynamic dispatch. /// By setting these as a constant they can be inlined into the interpreter diff --git a/src/lib/deploy/LibInterpreterDeploy.sol b/src/lib/deploy/LibInterpreterDeploy.sol index 4f80aa65e..b7430704b 100644 --- a/src/lib/deploy/LibInterpreterDeploy.sol +++ b/src/lib/deploy/LibInterpreterDeploy.sol @@ -55,12 +55,12 @@ library LibInterpreterDeploy { /// The address of the `RainterpreterDISPaiRegistry` contract when deployed /// with the rain standard zoltu deployer. - address constant DISPAIR_REGISTRY_DEPLOYED_ADDRESS = address(0x10B6e5DF88894d20d27E23Dd62Fb6741AC760676); + address constant DISPAIR_REGISTRY_DEPLOYED_ADDRESS = address(0xb0C5ec55F355e8FaB78D4c82437D0dF2f4688a67); /// The code hash of the `RainterpreterDISPaiRegistry` contract when /// deployed with the rain standard zoltu deployer. This can be used to /// verify that the deployed contract has the expected bytecode, which /// provides stronger guarantees than just checking the address. bytes32 constant DISPAIR_REGISTRY_DEPLOYED_CODEHASH = - bytes32(0x44bdd025183eb606fb828fa64322aa81b3da6a525e5dfde49e8dbe1fbce56b4b); + bytes32(0x79edc50d772f7f1c1105742f21a20089bea85ad001fe7f156ef3ac0b3e7a1191); } diff --git a/src/lib/extern/reference/literal/LibParseLiteralRepeat.sol b/src/lib/extern/reference/literal/LibParseLiteralRepeat.sol index 5671bc5ec..b771df60e 100644 --- a/src/lib/extern/reference/literal/LibParseLiteralRepeat.sol +++ b/src/lib/extern/reference/literal/LibParseLiteralRepeat.sol @@ -27,6 +27,12 @@ pragma solidity ^0.8.25; /// [ref-extern-repeat-3 123] /// ``` +/// @dev The maximum length of a repeat literal body (exclusive). Length must +/// be strictly less than this value. For length 77 the worst-case accumulation +/// is 10^77 - 1, which fits in uint256 (10^77 < 2^256). Length 78 would +/// accumulate up to 10^78 - 1, which overflows (10^78 > 2^256). +uint256 constant MAX_REPEAT_LITERAL_LENGTH = 78; + /// @dev Thrown when a repeat literal body exceeds the maximum length that can /// be computed without overflow in `10 ** i`. /// @param length The length of the literal body. @@ -52,13 +58,13 @@ library LibParseLiteralRepeat { uint256 value = 0; // Safe: cursor always <= end (parser invariant). uint256 length = end - cursor; - if (length >= 78) { + if (length >= MAX_REPEAT_LITERAL_LENGTH) { revert RepeatLiteralTooLong(length); } for (uint256 i = 0; i < length; ++i) { - // Safe: i < 78, so 10**i < 10**78 < 2^256. - // dispatchValue <= 9, so dispatchValue * 10**i <= 9 * 10**77 < 2^256. - // value accumulates at most 78 terms, sum < 10**78 < 2^256. + // Safe: i <= 76, so 10**i <= 10**76 < 2^256. + // dispatchValue <= 9, so dispatchValue * 10**i <= 9 * 10**76 < 2^256. + // value accumulates at most 77 terms, sum <= 10**77 - 1 < 2^256. value += dispatchValue * 10 ** i; } return value; diff --git a/src/lib/extern/reference/op/LibExternOpIntInc.sol b/src/lib/extern/reference/op/LibExternOpIntInc.sol index 6ef31a503..b1d2537c1 100644 --- a/src/lib/extern/reference/op/LibExternOpIntInc.sol +++ b/src/lib/extern/reference/op/LibExternOpIntInc.sol @@ -25,9 +25,11 @@ library LibExternOpIntInc { /// @return The incremented values. //slither-disable-next-line dead-code function run(OperandV2, StackItem[] memory inputs) internal pure returns (StackItem[] memory) { + // packLossless(1e37, -37) encodes the decimal float value 1. + Float one = LibDecimalFloat.packLossless(1e37, -37); for (uint256 i = 0; i < inputs.length; i++) { Float a = Float.wrap(StackItem.unwrap(inputs[i])); - a = a.add(LibDecimalFloat.packLossless(1e37, -37)); + a = a.add(one); inputs[i] = StackItem.wrap(Float.unwrap(a)); } return inputs; diff --git a/src/lib/op/bitwise/LibOpDecodeBits.sol b/src/lib/op/bitwise/LibOpDecodeBits.sol index 20396477a..df2deeccd 100644 --- a/src/lib/op/bitwise/LibOpDecodeBits.sol +++ b/src/lib/op/bitwise/LibOpDecodeBits.sol @@ -65,7 +65,7 @@ library LibOpDecodeBits { function referenceFn(InterpreterState memory, OperandV2 operand, StackItem[] memory inputs) internal pure - returns (StackItem[] memory outputs) + returns (StackItem[] memory) { // We decode as a start and length of bits. This avoids mistakes such as // inclusive/exclusive ranges, and makes it easier to reason about the @@ -77,7 +77,7 @@ library LibOpDecodeBits { // is 255. A 256 length doesn't really make sense as that isn't an // encoding anyway, it's just the value verbatim. uint256 mask = (2 ** length) - 1; - outputs = new StackItem[](1); - outputs[0] = StackItem.wrap(bytes32((uint256(StackItem.unwrap(inputs[0])) >> startBit) & mask)); + inputs[0] = StackItem.wrap(bytes32((uint256(StackItem.unwrap(inputs[0])) >> startBit) & mask)); + return inputs; } } diff --git a/src/lib/op/store/LibOpSet.sol b/src/lib/op/store/LibOpSet.sol index aae9124f3..b638daef4 100644 --- a/src/lib/op/store/LibOpSet.sol +++ b/src/lib/op/store/LibOpSet.sol @@ -27,18 +27,16 @@ library LibOpSet { /// @param stackTop Pointer to the top of the stack. /// @return The new stack top pointer after execution. function run(InterpreterState memory state, OperandV2, Pointer stackTop) internal pure returns (Pointer) { - unchecked { - bytes32 key; - bytes32 value; - assembly ("memory-safe") { - key := mload(stackTop) - value := mload(add(stackTop, 0x20)) - stackTop := add(stackTop, 0x40) - } - - state.stateKV = state.stateKV.set(MemoryKVKey.wrap(key), MemoryKVVal.wrap(value)); - return stackTop; + bytes32 key; + bytes32 value; + assembly ("memory-safe") { + key := mload(stackTop) + value := mload(add(stackTop, 0x20)) + stackTop := add(stackTop, 0x40) } + + state.stateKV = state.stateKV.set(MemoryKVKey.wrap(key), MemoryKVVal.wrap(value)); + return stackTop; } /// @notice Reference implementation of `set` for testing. diff --git a/src/lib/parse/LibParseState.sol b/src/lib/parse/LibParseState.sol index dd706223c..affbe74dd 100644 --- a/src/lib/parse/LibParseState.sol +++ b/src/lib/parse/LibParseState.sol @@ -531,6 +531,9 @@ library LibParseState { newStackRHSOffset := add(byte(0, mload(stackRHSOffsetPtr)), 1) mstore8(stackRHSOffsetPtr, newStackRHSOffset) } + // 0x3f = 63: the RHS offset is stored as a single byte within + // the topLevel0 field, but shares the word with other packed + // fields, so it must not exceed 62 (0x3e). if (newStackRHSOffset >= 0x3f) { revert ParseStackOverflow(); } diff --git a/test/src/concrete/Rainterpreter.eval.nonZeroSourceIndex.t.sol b/test/src/concrete/Rainterpreter.eval.nonZeroSourceIndex.t.sol new file mode 100644 index 000000000..bd5703377 --- /dev/null +++ b/test/src/concrete/Rainterpreter.eval.nonZeroSourceIndex.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import { + RainterpreterExpressionDeployerDeploymentTest +} from "test/abstract/RainterpreterExpressionDeployerDeploymentTest.sol"; +import {StateNamespace} from "rain.interpreter.interface/interface/IInterpreterStoreV3.sol"; +import {EvalV4, SourceIndexV2, StackItem} from "rain.interpreter.interface/interface/IInterpreterV4.sol"; +import {LibNamespace} from "rain.interpreter.interface/lib/ns/LibNamespace.sol"; +import {Float, LibDecimalFloat} from "rain.math.float/lib/LibDecimalFloat.sol"; + +/// @title RainterpreterEvalNonZeroSourceIndexTest +/// @notice Tests that `eval4` correctly selects non-zero source indices. +contract RainterpreterEvalNonZeroSourceIndexTest is RainterpreterExpressionDeployerDeploymentTest { + using LibDecimalFloat for Float; + + /// @notice eval4 with sourceIndex = 1 MUST evaluate the second source, not + /// the first. + function testEvalNonZeroSourceIndex() external view { + // Source 0 produces 42. Source 1 produces 99. + bytes memory bytecode = I_DEPLOYER.parse2("_: 42;_: 99;"); + + (StackItem[] memory stack,) = I_INTERPRETER.eval4( + EvalV4({ + store: I_STORE, + namespace: LibNamespace.qualifyNamespace(StateNamespace.wrap(0), address(this)), + bytecode: bytecode, + sourceIndex: SourceIndexV2.wrap(1), + context: new bytes32[][](0), + inputs: new StackItem[](0), + stateOverlay: new bytes32[](0) + }) + ); + + assertEq(stack.length, 1, "source 1 should produce exactly 1 output"); + Float expected = LibDecimalFloat.packLossless(99, 0); + assertTrue(Float.wrap(StackItem.unwrap(stack[0])).eq(expected), "source 1 should return 99"); + } + + /// @notice eval4 with sourceIndex = 1 where source 1 expects inputs MUST + /// work when correct inputs are provided. + function testEvalNonZeroSourceIndexWithInputs() external view { + // Source 0: no-op. Source 1: takes one input and adds 1 to it. + bytes memory bytecode = I_DEPLOYER.parse2("_: 42;x:, _: add(x 1);"); + + Float inputVal = LibDecimalFloat.packLossless(10, 0); + StackItem[] memory inputs = new StackItem[](1); + inputs[0] = StackItem.wrap(Float.unwrap(inputVal)); + + (StackItem[] memory stack,) = I_INTERPRETER.eval4( + EvalV4({ + store: I_STORE, + namespace: LibNamespace.qualifyNamespace(StateNamespace.wrap(0), address(this)), + bytecode: bytecode, + sourceIndex: SourceIndexV2.wrap(1), + context: new bytes32[][](0), + inputs: inputs, + stateOverlay: new bytes32[](0) + }) + ); + + assertEq(stack.length, 2, "source 1 should produce 2 outputs (1 input + 1 op)"); + Float expected = LibDecimalFloat.packLossless(11, 0); + assertTrue(Float.wrap(StackItem.unwrap(stack[0])).eq(expected), "source 1 should return add(10, 1) = 11"); + } + + /// @notice Source index 0 and source index 1 produce different results from + /// the same bytecode, confirming sourceIndex is respected. + function testEvalSourceIndexSelectsCorrectSource() external view { + bytes memory bytecode = I_DEPLOYER.parse2("_: 7;_: 13;"); + + (StackItem[] memory stack0,) = I_INTERPRETER.eval4( + EvalV4({ + store: I_STORE, + namespace: LibNamespace.qualifyNamespace(StateNamespace.wrap(0), address(this)), + bytecode: bytecode, + sourceIndex: SourceIndexV2.wrap(0), + context: new bytes32[][](0), + inputs: new StackItem[](0), + stateOverlay: new bytes32[](0) + }) + ); + + (StackItem[] memory stack1,) = I_INTERPRETER.eval4( + EvalV4({ + store: I_STORE, + namespace: LibNamespace.qualifyNamespace(StateNamespace.wrap(0), address(this)), + bytecode: bytecode, + sourceIndex: SourceIndexV2.wrap(1), + context: new bytes32[][](0), + inputs: new StackItem[](0), + stateOverlay: new bytes32[](0) + }) + ); + + Float expected0 = LibDecimalFloat.packLossless(7, 0); + Float expected1 = LibDecimalFloat.packLossless(13, 0); + assertTrue(Float.wrap(StackItem.unwrap(stack0[0])).eq(expected0), "source 0 should return 7"); + assertTrue(Float.wrap(StackItem.unwrap(stack1[0])).eq(expected1), "source 1 should return 13"); + } +} diff --git a/test/src/concrete/Rainterpreter.eval.t.sol b/test/src/concrete/Rainterpreter.eval.t.sol index c93450ee6..e3a469e21 100644 --- a/test/src/concrete/Rainterpreter.eval.t.sol +++ b/test/src/concrete/Rainterpreter.eval.t.sol @@ -45,6 +45,8 @@ contract RainterpreterEvalTest is RainterpreterExpressionDeployerDeploymentTest // Build rainlang with expectedInputs LHS names: "a b c ... :;" bytes memory rainlang = new bytes(2 * uint256(expectedInputs) + 1); for (uint256 i = 0; i < expectedInputs; i++) { + // Safe: i is bounded by expectedInputs which is bounded to <= 15 by the test. + //forge-lint: disable-next-line(unsafe-typecast) rainlang[i * 2] = bytes1(uint8(0x61) + uint8(i)); if (i < uint256(expectedInputs) - 1) { rainlang[i * 2 + 1] = " "; diff --git a/test/src/concrete/Rainterpreter.stateOverlay.t.sol b/test/src/concrete/Rainterpreter.stateOverlay.t.sol index b0814ebaa..c05cc4360 100644 --- a/test/src/concrete/Rainterpreter.stateOverlay.t.sol +++ b/test/src/concrete/Rainterpreter.stateOverlay.t.sol @@ -61,6 +61,152 @@ contract RainterpreterStateOverlayTest is RainterpreterExpressionDeployerDeploym assertEq(kvs[1], v); } + /// @notice stateOverlay with two key-value pairs MUST apply both to the + /// state KV. Both keys should be readable via `get` in the evaluated source. + function testStateOverlayMultiplePairs() external view { + bytes memory bytecode = I_DEPLOYER.parse2("a b: get(1) get(2);"); + + bytes32 k1 = Float.unwrap(LibDecimalFloat.packLossless(1, 0)); + bytes32 v1 = bytes32(uint256(100)); + bytes32 k2 = Float.unwrap(LibDecimalFloat.packLossless(2, 0)); + bytes32 v2 = bytes32(uint256(200)); + + bytes32[] memory stateOverlay = new bytes32[](4); + stateOverlay[0] = k1; + stateOverlay[1] = v1; + stateOverlay[2] = k2; + stateOverlay[3] = v2; + + (StackItem[] memory stack, bytes32[] memory kvs) = I_INTERPRETER.eval4( + EvalV4({ + store: I_STORE, + namespace: LibNamespace.qualifyNamespace(StateNamespace.wrap(0), address(this)), + bytecode: bytecode, + sourceIndex: SourceIndexV2.wrap(0), + context: new bytes32[][](0), + inputs: new StackItem[](0), + stateOverlay: stateOverlay + }) + ); + + assertEq(stack.length, 2, "two get ops should produce two stack items"); + assertEq(StackItem.unwrap(stack[0]), v2, "get(2) should return v2"); + assertEq(StackItem.unwrap(stack[1]), v1, "get(1) should return v1"); + assertEq(kvs.length, 4, "kvs should contain two key-value pairs"); + } + + /// @notice stateOverlay with three key-value pairs MUST apply all three. + function testStateOverlayThreePairs() external view { + bytes memory bytecode = I_DEPLOYER.parse2("a b c: get(1) get(2) get(3);"); + + bytes32 k1 = Float.unwrap(LibDecimalFloat.packLossless(1, 0)); + bytes32 v1 = bytes32(uint256(10)); + bytes32 k2 = Float.unwrap(LibDecimalFloat.packLossless(2, 0)); + bytes32 v2 = bytes32(uint256(20)); + bytes32 k3 = Float.unwrap(LibDecimalFloat.packLossless(3, 0)); + bytes32 v3 = bytes32(uint256(30)); + + bytes32[] memory stateOverlay = new bytes32[](6); + stateOverlay[0] = k1; + stateOverlay[1] = v1; + stateOverlay[2] = k2; + stateOverlay[3] = v2; + stateOverlay[4] = k3; + stateOverlay[5] = v3; + + (StackItem[] memory stack, bytes32[] memory kvs) = I_INTERPRETER.eval4( + EvalV4({ + store: I_STORE, + namespace: LibNamespace.qualifyNamespace(StateNamespace.wrap(0), address(this)), + bytecode: bytecode, + sourceIndex: SourceIndexV2.wrap(0), + context: new bytes32[][](0), + inputs: new StackItem[](0), + stateOverlay: stateOverlay + }) + ); + + assertEq(stack.length, 3, "three get ops should produce three stack items"); + assertEq(StackItem.unwrap(stack[0]), v3, "get(3) should return v3"); + assertEq(StackItem.unwrap(stack[1]), v2, "get(2) should return v2"); + assertEq(StackItem.unwrap(stack[2]), v1, "get(1) should return v1"); + assertEq(kvs.length, 6, "kvs should contain three key-value pairs"); + } + + /// @notice When stateOverlay contains the same key twice, the LAST value + /// for that key MUST win (last-write-wins semantics from sequential `set` + /// calls). + function testStateOverlayDuplicateKeyLastWins() external view { + bytes memory bytecode = I_DEPLOYER.parse2("_: get(5);"); + + bytes32 k = Float.unwrap(LibDecimalFloat.packLossless(5, 0)); + bytes32 v1 = bytes32(uint256(111)); + bytes32 v2 = bytes32(uint256(222)); + + bytes32[] memory stateOverlay = new bytes32[](4); + stateOverlay[0] = k; + stateOverlay[1] = v1; + stateOverlay[2] = k; + stateOverlay[3] = v2; + + (StackItem[] memory stack, bytes32[] memory kvs) = I_INTERPRETER.eval4( + EvalV4({ + store: I_STORE, + namespace: LibNamespace.qualifyNamespace(StateNamespace.wrap(0), address(this)), + bytecode: bytecode, + sourceIndex: SourceIndexV2.wrap(0), + context: new bytes32[][](0), + inputs: new StackItem[](0), + stateOverlay: stateOverlay + }) + ); + + assertEq(stack.length, 1, "single get should produce one stack item"); + assertEq(StackItem.unwrap(stack[0]), v2, "duplicate key in overlay: last value should win"); + assertEq(kvs.length, 2, "kvs should contain one key-value pair (deduplicated)"); + assertEq(kvs[0], k, "kvs key should be the overlay key"); + assertEq(kvs[1], v2, "kvs value should be the last-written value"); + } + + /// @notice When stateOverlay has duplicate keys interleaved with other + /// keys, each duplicate key's last value MUST win independently. + function testStateOverlayDuplicateKeysInterleaved() external view { + bytes memory bytecode = I_DEPLOYER.parse2("a b: get(1) get(2);"); + + bytes32 k1 = Float.unwrap(LibDecimalFloat.packLossless(1, 0)); + bytes32 k2 = Float.unwrap(LibDecimalFloat.packLossless(2, 0)); + bytes32 v1First = bytes32(uint256(10)); + bytes32 v2First = bytes32(uint256(20)); + bytes32 v1Second = bytes32(uint256(11)); + bytes32 v2Second = bytes32(uint256(22)); + + bytes32[] memory stateOverlay = new bytes32[](8); + stateOverlay[0] = k1; + stateOverlay[1] = v1First; + stateOverlay[2] = k2; + stateOverlay[3] = v2First; + stateOverlay[4] = k1; + stateOverlay[5] = v1Second; + stateOverlay[6] = k2; + stateOverlay[7] = v2Second; + + (StackItem[] memory stack,) = I_INTERPRETER.eval4( + EvalV4({ + store: I_STORE, + namespace: LibNamespace.qualifyNamespace(StateNamespace.wrap(0), address(this)), + bytecode: bytecode, + sourceIndex: SourceIndexV2.wrap(0), + context: new bytes32[][](0), + inputs: new StackItem[](0), + stateOverlay: stateOverlay + }) + ); + + assertEq(stack.length, 2, "two get ops should produce two stack items"); + assertEq(StackItem.unwrap(stack[0]), v2Second, "get(2) should return the second value for k2"); + assertEq(StackItem.unwrap(stack[1]), v1Second, "get(1) should return the second value for k1"); + } + /// Show that state overlay can be overridden by a set in the bytecode. function testStateOverlaySet() external view { bytes memory bytecode = I_DEPLOYER.parse2("_:get(9),:set(9 42),_:get(9);"); diff --git a/test/src/concrete/RainterpreterDISPaiRegistry.ierc165.t.sol b/test/src/concrete/RainterpreterDISPaiRegistry.ierc165.t.sol new file mode 100644 index 000000000..38931cdc8 --- /dev/null +++ b/test/src/concrete/RainterpreterDISPaiRegistry.ierc165.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; + +import {IERC165} from "openzeppelin-contracts/contracts/utils/introspection/IERC165.sol"; +import {RainterpreterDISPaiRegistry} from "src/concrete/RainterpreterDISPaiRegistry.sol"; +import {IDISPaiRegistry} from "src/interface/IDISPaiRegistry.sol"; + +/// @title RainterpreterDISPaiRegistryIERC165Test +/// @notice Test that ERC165 is implemented for the DISPaiR registry. +contract RainterpreterDISPaiRegistryIERC165Test is Test { + /// Test that ERC165 is implemented for all interfaces. + function testRainterpreterDISPaiRegistryIERC165(bytes4 badInterfaceId) external { + vm.assume(badInterfaceId != type(IERC165).interfaceId); + vm.assume(badInterfaceId != type(IDISPaiRegistry).interfaceId); + + RainterpreterDISPaiRegistry registry = new RainterpreterDISPaiRegistry(); + assertTrue(registry.supportsInterface(type(IERC165).interfaceId)); + assertTrue(registry.supportsInterface(type(IDISPaiRegistry).interfaceId)); + + assertFalse(registry.supportsInterface(badInterfaceId)); + } +} diff --git a/test/src/concrete/RainterpreterParser.parsePragmaEmpty.t.sol b/test/src/concrete/RainterpreterParser.parsePragmaEmpty.t.sol new file mode 100644 index 000000000..f0349a18a --- /dev/null +++ b/test/src/concrete/RainterpreterParser.parsePragmaEmpty.t.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {RainterpreterParser} from "src/concrete/RainterpreterParser.sol"; +import {PragmaV1} from "rain.interpreter.interface/interface/IParserPragmaV1.sol"; + +/// @title RainterpreterParserParsePragmaEmptyInputTest +/// @notice A48-4: Test that `parsePragma1` handles empty input correctly. +contract RainterpreterParserParsePragmaEmptyInputTest is Test { + /// @notice parsePragma1 with empty bytes should return a PragmaV1 with an + /// empty usingWordsFrom array. + function testParsePragma1EmptyInput() external { + RainterpreterParser parser = new RainterpreterParser(); + PragmaV1 memory pragma_ = parser.parsePragma1(bytes("")); + assertEq(pragma_.usingWordsFrom.length, 0, "empty input should produce zero sub-parsers"); + } + + /// @notice parsePragma1 with a single null byte should also not revert and + /// should produce zero sub-parsers. + function testParsePragma1SingleNullByte() external { + RainterpreterParser parser = new RainterpreterParser(); + PragmaV1 memory pragma_ = parser.parsePragma1(bytes(hex"00")); + assertEq(pragma_.usingWordsFrom.length, 0, "null byte should produce zero sub-parsers"); + } +} diff --git a/test/src/concrete/RainterpreterReferenceExtern.repeat.t.sol b/test/src/concrete/RainterpreterReferenceExtern.repeat.t.sol index 1ba68b3d6..2710cf02e 100644 --- a/test/src/concrete/RainterpreterReferenceExtern.repeat.t.sol +++ b/test/src/concrete/RainterpreterReferenceExtern.repeat.t.sol @@ -28,32 +28,32 @@ contract RainterpreterReferenceExternRepeatTest is OpTest { checkHappy(bytes(string.concat(baseStr, "eighteight: [ref-extern-repeat-8 zz];")), expectedStack, "repeat 8 zz"); } - /// Negative repeat count must revert. + /// Negative repeat count must revert with InvalidRepeatCount. function testRainterpreterReferenceExternRepeatNegative() external { RainterpreterReferenceExtern extern = new RainterpreterReferenceExtern(); string memory baseStr = string.concat("using-words-from ", address(extern).toHexString(), " "); - vm.expectRevert(); + vm.expectRevert(abi.encodeWithSelector(InvalidRepeatCount.selector)); bytes memory bytecode = I_DEPLOYER.parse2(bytes(string.concat(baseStr, "_: [ref-extern-repeat--1 abc];"))); (bytecode); } - /// Non-integer repeat count (e.g. 1.5) must revert. + /// Non-integer repeat count (e.g. 1.5) must revert with InvalidRepeatCount. function testRainterpreterReferenceExternRepeatNonInteger() external { RainterpreterReferenceExtern extern = new RainterpreterReferenceExtern(); string memory baseStr = string.concat("using-words-from ", address(extern).toHexString(), " "); - vm.expectRevert(); + vm.expectRevert(abi.encodeWithSelector(InvalidRepeatCount.selector)); bytes memory bytecode = I_DEPLOYER.parse2(bytes(string.concat(baseStr, "_: [ref-extern-repeat-1.5 abc];"))); (bytecode); } - /// Repeat count > 9 must revert. + /// Repeat count > 9 must revert with InvalidRepeatCount. function testRainterpreterReferenceExternRepeatTooLarge() external { RainterpreterReferenceExtern extern = new RainterpreterReferenceExtern(); string memory baseStr = string.concat("using-words-from ", address(extern).toHexString(), " "); - vm.expectRevert(); + vm.expectRevert(abi.encodeWithSelector(InvalidRepeatCount.selector)); bytes memory bytecode = I_DEPLOYER.parse2(bytes(string.concat(baseStr, "_: [ref-extern-repeat-10 abc];"))); (bytecode); } diff --git a/test/src/concrete/RainterpreterReferenceExtern.subParserIndexOutOfBounds.t.sol b/test/src/concrete/RainterpreterReferenceExtern.subParserIndexOutOfBounds.t.sol new file mode 100644 index 000000000..18eaa2ad9 --- /dev/null +++ b/test/src/concrete/RainterpreterReferenceExtern.subParserIndexOutOfBounds.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {RainterpreterReferenceExtern} from "src/concrete/extern/RainterpreterReferenceExtern.sol"; +import {SubParserIndexOutOfBounds} from "src/error/ErrSubParse.sol"; + +/// @dev Mock subclass that forces matchSubParseLiteralDispatch to return an +/// out-of-bounds index, triggering the SubParserIndexOutOfBounds check. +contract MockExternBadLiteralIndex is RainterpreterReferenceExtern { + /// @notice Override to always return success with an out-of-bounds index. + function matchSubParseLiteralDispatch(uint256, uint256) internal pure override returns (bool, uint256, bytes32) { + return (true, 999, bytes32(0)); + } +} + +/// @title RainterpreterReferenceExternSubParserIndexOutOfBoundsTest +/// @notice A49-3: Test that `SubParserIndexOutOfBounds` reverts when an +/// out-of-bounds index is returned from the literal dispatch lookup. +contract RainterpreterReferenceExternSubParserIndexOutOfBoundsTest is Test { + /// @notice Calling subParseLiteral2 on a mock that returns an out-of-bounds + /// literal parser index must revert with SubParserIndexOutOfBounds. + function testSubParseLiteral2IndexOutOfBounds() external { + MockExternBadLiteralIndex ext = new MockExternBadLiteralIndex(); + + bytes memory data = abi.encodePacked(uint16(4), bytes4(0x01020304), bytes4(0x05060708)); + + vm.expectRevert(abi.encodeWithSelector(SubParserIndexOutOfBounds.selector, uint256(999), uint256(1))); + ext.subParseLiteral2(data); + } +} diff --git a/test/src/concrete/RainterpreterStore.getUninitialized.t.sol b/test/src/concrete/RainterpreterStore.getUninitialized.t.sol new file mode 100644 index 000000000..c8f01456d --- /dev/null +++ b/test/src/concrete/RainterpreterStore.getUninitialized.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {RainterpreterStore} from "src/concrete/RainterpreterStore.sol"; +import { + LibNamespace, + FullyQualifiedNamespace, + StateNamespace +} from "rain.interpreter.interface/lib/ns/LibNamespace.sol"; + +/// @title RainterpreterStoreGetUninitializedTest +/// @notice A50-4: Test that `get()` returns `bytes32(0)` for a key that has +/// never been set. +contract RainterpreterStoreGetUninitializedTest is Test { + using LibNamespace for StateNamespace; + + /// @notice get() on a key that was never set must return bytes32(0). + function testGetUninitializedKey() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(1); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32 key = bytes32(uint256(0xdeadbeef)); + bytes32 value = store.get(fqn, key); + assertEq(value, bytes32(0), "uninitialized key must return 0"); + } + + /// @notice Fuzz: get() on any never-set namespace+key must return 0. + function testGetUninitializedKeyFuzz(StateNamespace namespace, bytes32 key) external { + RainterpreterStore store = new RainterpreterStore(); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32 value = store.get(fqn, key); + assertEq(value, bytes32(0), "uninitialized key must return 0 (fuzz)"); + } + + /// @notice After setting a different key, the original uninitialized key + /// must still return 0. + function testGetUninitializedAfterSetDifferentKey() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(1); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32 setKey = bytes32(uint256(1)); + bytes32 setValue = bytes32(uint256(999)); + bytes32[] memory kvs = new bytes32[](2); + kvs[0] = setKey; + kvs[1] = setValue; + store.set(namespace, kvs); + + bytes32 otherKey = bytes32(uint256(2)); + bytes32 value = store.get(fqn, otherKey); + assertEq(value, bytes32(0), "different key must still return 0"); + } +} diff --git a/test/src/concrete/RainterpreterStore.overwriteKey.t.sol b/test/src/concrete/RainterpreterStore.overwriteKey.t.sol new file mode 100644 index 000000000..17b4c0f7d --- /dev/null +++ b/test/src/concrete/RainterpreterStore.overwriteKey.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {RainterpreterStore} from "src/concrete/RainterpreterStore.sol"; +import { + LibNamespace, + FullyQualifiedNamespace, + StateNamespace +} from "rain.interpreter.interface/lib/ns/LibNamespace.sol"; + +/// @title RainterpreterStoreOverwriteKeyTest +/// @notice A50-5: Test that a key appearing twice in a single `kvs` array +/// results in the last value winning. +contract RainterpreterStoreOverwriteKeyTest is Test { + using LibNamespace for StateNamespace; + + /// @notice A key appearing twice in a single set call must result in the + /// last value being stored (last-write-wins). + function testOverwriteKeyLastValueWins() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(1); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32 key = bytes32(uint256(42)); + bytes32 firstValue = bytes32(uint256(100)); + bytes32 secondValue = bytes32(uint256(200)); + + bytes32[] memory kvs = new bytes32[](4); + kvs[0] = key; + kvs[1] = firstValue; + kvs[2] = key; + kvs[3] = secondValue; + + store.set(namespace, kvs); + + bytes32 stored = store.get(fqn, key); + assertEq(stored, secondValue, "last value must win"); + } + + /// @notice A key appearing three times — the last value must persist. + function testOverwriteKeyTriple() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(2); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32 key = bytes32(uint256(99)); + + bytes32[] memory kvs = new bytes32[](6); + kvs[0] = key; + kvs[1] = bytes32(uint256(1)); + kvs[2] = key; + kvs[3] = bytes32(uint256(2)); + kvs[4] = key; + kvs[5] = bytes32(uint256(3)); + + store.set(namespace, kvs); + assertEq(store.get(fqn, key), bytes32(uint256(3)), "triple overwrite: last value must win"); + } + + /// @notice Overwriting a key among other unique keys in the same array. + function testOverwriteKeyAmongOtherKeys() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(3); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32 dupeKey = bytes32(uint256(10)); + bytes32 uniqueKey = bytes32(uint256(20)); + + bytes32[] memory kvs = new bytes32[](6); + kvs[0] = dupeKey; + kvs[1] = bytes32(uint256(100)); + kvs[2] = uniqueKey; + kvs[3] = bytes32(uint256(200)); + kvs[4] = dupeKey; + kvs[5] = bytes32(uint256(300)); + + store.set(namespace, kvs); + + assertEq(store.get(fqn, dupeKey), bytes32(uint256(300)), "dupe key: last value"); + assertEq(store.get(fqn, uniqueKey), bytes32(uint256(200)), "unique key: unchanged"); + } +} diff --git a/test/src/concrete/RainterpreterStore.setEmpty.t.sol b/test/src/concrete/RainterpreterStore.setEmpty.t.sol new file mode 100644 index 000000000..e176f4abf --- /dev/null +++ b/test/src/concrete/RainterpreterStore.setEmpty.t.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {RainterpreterStore} from "src/concrete/RainterpreterStore.sol"; +import {StateNamespace} from "rain.interpreter.interface/lib/ns/LibNamespace.sol"; + +/// @title RainterpreterStoreSetEmptyArrayTest +/// @notice A50-3: Test that `set()` with an empty (zero-length) `kvs` array +/// succeeds without reverting. +contract RainterpreterStoreSetEmptyArrayTest is Test { + /// @notice set() with a zero-length kvs array must not revert. + function testSetEmptyArray() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(1); + + bytes32[] memory kvs = new bytes32[](0); + store.set(namespace, kvs); + } + + /// @notice set() with a zero-length kvs array should not emit any Set + /// events. + function testSetEmptyArrayNoEvents() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(1); + + bytes32[] memory kvs = new bytes32[](0); + + vm.recordLogs(); + store.set(namespace, kvs); + + assertEq(vm.getRecordedLogs().length, 0, "empty set should emit no events"); + } + + /// @notice Fuzz variant: set() with empty array and any namespace must not + /// revert. + function testSetEmptyArrayFuzz(StateNamespace namespace) external { + RainterpreterStore store = new RainterpreterStore(); + bytes32[] memory kvs = new bytes32[](0); + store.set(namespace, kvs); + } +} diff --git a/test/src/concrete/RainterpreterStore.setEvent.t.sol b/test/src/concrete/RainterpreterStore.setEvent.t.sol new file mode 100644 index 000000000..ec1e88a27 --- /dev/null +++ b/test/src/concrete/RainterpreterStore.setEvent.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {RainterpreterStore} from "src/concrete/RainterpreterStore.sol"; +import { + LibNamespace, + FullyQualifiedNamespace, + StateNamespace +} from "rain.interpreter.interface/lib/ns/LibNamespace.sol"; +import {IInterpreterStoreV3} from "rain.interpreter.interface/interface/IInterpreterStoreV3.sol"; + +/// @title RainterpreterStoreSetEventTest +/// @notice A50-2: Test that the `Set` event is emitted correctly for every +/// key-value pair stored via `set()`. +contract RainterpreterStoreSetEventTest is Test { + using LibNamespace for StateNamespace; + + /// @notice A single key-value pair should emit exactly one Set event with + /// the correct fullyQualifiedNamespace, key, and value. + function testSetEventSinglePair() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(42); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32 key = bytes32(uint256(1)); + bytes32 value = bytes32(uint256(100)); + + bytes32[] memory kvs = new bytes32[](2); + kvs[0] = key; + kvs[1] = value; + + vm.expectEmit(true, true, true, true); + emit IInterpreterStoreV3.Set(fqn, key, value); + store.set(namespace, kvs); + } + + /// @notice Multiple key-value pairs should emit one Set event per pair. + function testSetEventMultiplePairs() external { + RainterpreterStore store = new RainterpreterStore(); + StateNamespace namespace = StateNamespace.wrap(7); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32[] memory kvs = new bytes32[](4); + kvs[0] = bytes32(uint256(1)); + kvs[1] = bytes32(uint256(10)); + kvs[2] = bytes32(uint256(2)); + kvs[3] = bytes32(uint256(20)); + + vm.expectEmit(true, true, true, true); + emit IInterpreterStoreV3.Set(fqn, kvs[0], kvs[1]); + vm.expectEmit(true, true, true, true); + emit IInterpreterStoreV3.Set(fqn, kvs[2], kvs[3]); + + store.set(namespace, kvs); + } + + /// @notice The fullyQualifiedNamespace in the event must match what + /// qualifyNamespace produces for the msg.sender. + function testSetEventFQNMatchesQualifyNamespace(StateNamespace namespace, bytes32 key, bytes32 value) external { + RainterpreterStore store = new RainterpreterStore(); + FullyQualifiedNamespace fqn = namespace.qualifyNamespace(address(this)); + + bytes32[] memory kvs = new bytes32[](2); + kvs[0] = key; + kvs[1] = value; + + vm.expectEmit(true, true, true, true); + emit IInterpreterStoreV3.Set(fqn, key, value); + store.set(namespace, kvs); + } +} diff --git a/test/src/lib/op/LibAllStandardOps.t.sol b/test/src/lib/op/LibAllStandardOps.t.sol index 3561c2075..39383bb45 100644 --- a/test/src/lib/op/LibAllStandardOps.t.sol +++ b/test/src/lib/op/LibAllStandardOps.t.sol @@ -68,9 +68,14 @@ contract LibAllStandardOpsTest is Test { assertEq(words.length, ALL_STANDARD_OPS_LENGTH); // The first four opcodes must be in this order for parsing. + // Safe: string literals are <= 32 bytes, right-padded by Solidity. + //forge-lint: disable-next-line(unsafe-typecast) assertEq(words[0].word, bytes32("stack")); + //forge-lint: disable-next-line(unsafe-typecast) assertEq(words[1].word, bytes32("constant")); + //forge-lint: disable-next-line(unsafe-typecast) assertEq(words[2].word, bytes32("extern")); + //forge-lint: disable-next-line(unsafe-typecast) assertEq(words[3].word, bytes32("context")); // Every word must be non-empty. diff --git a/test/src/lib/op/evm/LibOpBlockNumber.t.sol b/test/src/lib/op/evm/LibOpBlockNumber.t.sol index 38e4ca801..f6eeda06e 100644 --- a/test/src/lib/op/evm/LibOpBlockNumber.t.sol +++ b/test/src/lib/op/evm/LibOpBlockNumber.t.sol @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {OpTest} from "test/abstract/OpTest.sol"; +import {OpTest, UnexpectedOperand} from "test/abstract/OpTest.sol"; import {LibPointer, Pointer} from "rain.solmem/lib/LibPointer.sol"; import {OperandV2, StackItem} from "rain.interpreter.interface/interface/IInterpreterV4.sol"; @@ -66,4 +66,9 @@ contract LibOpBlockNumberTest is OpTest { function testOpBlockNumberEvalTwoOutputs() external { checkBadOutputs("_ _: block-number();", 0, 1, 2); } + + /// Test that operand is disallowed. + function testOpBlockNumberEvalOperandDisallowed() external { + checkUnhappyParse("_: block-number<0>();", abi.encodeWithSelector(UnexpectedOperand.selector)); + } } diff --git a/test/src/lib/op/evm/LibOpChainId.t.sol b/test/src/lib/op/evm/LibOpChainId.t.sol index ccfad68a4..31e93b42c 100644 --- a/test/src/lib/op/evm/LibOpChainId.t.sol +++ b/test/src/lib/op/evm/LibOpChainId.t.sol @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {OpTest} from "test/abstract/OpTest.sol"; +import {OpTest, UnexpectedOperand} from "test/abstract/OpTest.sol"; import {LibInterpreterState, InterpreterState} from "src/lib/state/LibInterpreterState.sol"; import {IntegrityCheckState, BadOpInputsLength} from "src/lib/integrity/LibIntegrityCheck.sol"; @@ -60,4 +60,9 @@ contract LibOpChainIdTest is OpTest { function testOpChainIdTwoOutputs() external { checkBadOutputs("_ _: chain-id();", 0, 1, 2); } + + /// Test that operand is disallowed. + function testOpChainIdEvalOperandDisallowed() external { + checkUnhappyParse("_: chain-id<0>();", abi.encodeWithSelector(UnexpectedOperand.selector)); + } } diff --git a/test/src/lib/op/evm/LibOpTimestamp.t.sol b/test/src/lib/op/evm/LibOpTimestamp.t.sol index 263ea0056..302a65ef6 100644 --- a/test/src/lib/op/evm/LibOpTimestamp.t.sol +++ b/test/src/lib/op/evm/LibOpTimestamp.t.sol @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {OpTest} from "test/abstract/OpTest.sol"; +import {OpTest, UnexpectedOperand} from "test/abstract/OpTest.sol"; import {Pointer, LibPointer} from "rain.solmem/lib/LibPointer.sol"; import {LibStackPointer} from "rain.solmem/lib/LibStackPointer.sol"; import {LibInterpreterState} from "src/lib/state/LibInterpreterState.sol"; @@ -108,4 +108,14 @@ contract LibOpTimestampTest is OpTest { checkBadOutputs(bytes(string.concat("_ _: ", words[i], "();")), 0, 1, 2); } } + + /// Test that operand is disallowed for block-timestamp. + function testOpBlockTimestampEvalOperandDisallowed() external { + checkUnhappyParse("_: block-timestamp<0>();", abi.encodeWithSelector(UnexpectedOperand.selector)); + } + + /// Test that operand is disallowed for now. + function testOpNowEvalOperandDisallowed() external { + checkUnhappyParse("_: now<0>();", abi.encodeWithSelector(UnexpectedOperand.selector)); + } } diff --git a/test/src/lib/op/math/LibOpFloor.t.sol b/test/src/lib/op/math/LibOpFloor.t.sol index 1e1910fd6..385e1e78a 100644 --- a/test/src/lib/op/math/LibOpFloor.t.sol +++ b/test/src/lib/op/math/LibOpFloor.t.sol @@ -9,6 +9,8 @@ import {LibDecimalFloat, Float} from "rain.math.float/lib/LibDecimalFloat.sol"; import {StackItem} from "rain.interpreter.interface/interface/IInterpreterV4.sol"; contract LibOpFloorTest is OpTest { + using LibDecimalFloat for Float; + /// Directly test the integrity logic of LibOpFloor. /// Inputs are always 1, outputs are always 1. function testOpFloorIntegrity(IntegrityCheckState memory state, OperandV2 operand) external pure { @@ -38,6 +40,31 @@ contract LibOpFloorTest is OpTest { checkHappy("_: floor(3.8);", Float.unwrap(LibDecimalFloat.packLossless(30, -1)), "3.8"); } + /// Test the eval of `floor` with negative values. + function testOpFloorEvalNegative() external view { + checkHappy("_: floor(-1);", Float.unwrap(LibDecimalFloat.packLossless(-1, 0)), "-1"); + checkFloorFloat("_: floor(-1.1);", -2, 0, "-1.1"); + checkFloorFloat("_: floor(-0.5);", -1, 0, "-0.5"); + checkFloorFloat("_: floor(-1.5);", -2, 0, "-1.5"); + checkHappy("_: floor(-2);", Float.unwrap(LibDecimalFloat.packLossless(-2, 0)), "-2"); + checkFloorFloat("_: floor(-2.5);", -3, 0, "-2.5"); + } + + /// Helper to compare eval result using float equality rather than raw + /// bytes, since floor's internal normalization may differ from packLossless. + function checkFloorFloat( + bytes memory rainString, + int256 expectedCoefficient, + int256 expectedExponent, + string memory errString + ) internal view { + (StackItem[] memory stack,) = parseAndEval(rainString); + assertEq(stack.length, 1, errString); + Float result = Float.wrap(StackItem.unwrap(stack[0])); + Float expected = LibDecimalFloat.packLossless(expectedCoefficient, expectedExponent); + assertTrue(result.eq(expected), errString); + } + /// Test the eval of `floor` for bad inputs. function testOpFloorZeroInputs() external { checkBadInputs("_: floor();", 0, 1, 0); diff --git a/test/src/lib/op/math/LibOpInv.t.sol b/test/src/lib/op/math/LibOpInv.t.sol index d195d5427..00c0ca911 100644 --- a/test/src/lib/op/math/LibOpInv.t.sol +++ b/test/src/lib/op/math/LibOpInv.t.sol @@ -4,6 +4,7 @@ pragma solidity =0.8.25; import {OpTest, IntegrityCheckState, OperandV2, InterpreterState, UnexpectedOperand} from "test/abstract/OpTest.sol"; import {LibDecimalFloat, Float} from "rain.math.float/lib/LibDecimalFloat.sol"; +import {DivisionByZero} from "rain.math.float/error/ErrDecimalFloat.sol"; import {LibOpInv} from "src/lib/op/math/LibOpInv.sol"; import {LibOperand} from "test/lib/operand/LibOperand.sol"; import {StackItem} from "rain.interpreter.interface/interface/IInterpreterV4.sol"; @@ -48,6 +49,19 @@ contract LibOpInvTest is OpTest { ); } + /// Test the eval of `inv` with negative inputs. + function testOpInvEvalNegative() external view { + (Float expected,) = LibDecimalFloat.packLossy(-1e67, -67); + checkHappy("_: inv(-1);", Float.unwrap(expected), "-1"); + (expected,) = LibDecimalFloat.packLossy(-0.5e67, -67); + checkHappy("_: inv(-2);", Float.unwrap(expected), "-2"); + } + + /// Test that inv(0) reverts with division by zero. + function testOpInvEvalDivisionByZero() external { + checkUnhappy("_: inv(0);", abi.encodeWithSelector(DivisionByZero.selector, 1e76, -76)); + } + /// Test the eval of `inv` for bad inputs. function testOpInvZeroInputs() external { checkBadInputs("_: inv();", 0, 1, 0); diff --git a/test/src/lib/op/math/LibOpMax.t.sol b/test/src/lib/op/math/LibOpMax.t.sol index 3b2b52465..27c5f8782 100644 --- a/test/src/lib/op/math/LibOpMax.t.sol +++ b/test/src/lib/op/math/LibOpMax.t.sol @@ -62,6 +62,10 @@ contract LibOpMaxTest is OpTest { checkBadInputs("_: max(max-positive-value());", 1, 2, 1); } + function testOpMaxZeroOutputs() external { + checkBadOutputs(": max(0 0);", 2, 1, 0); + } + function testOpMaxEvalTwoOutputs() external { checkBadOutputs("_ _: max(0 0);", 2, 1, 2); } diff --git a/test/src/lib/op/math/LibOpMin.t.sol b/test/src/lib/op/math/LibOpMin.t.sol index 5d50e940c..c1d9e3de5 100644 --- a/test/src/lib/op/math/LibOpMin.t.sol +++ b/test/src/lib/op/math/LibOpMin.t.sol @@ -252,6 +252,14 @@ contract LibOpMinTest is OpTest { checkHappy("_: min(-1.1 -1.0 0);", Float.unwrap(LibDecimalFloat.packLossless(-11, -1)), "-1.1 -1.0 0"); } + function testOpMinZeroOutputs() external { + checkBadOutputs(": min(1 1);", 2, 1, 0); + } + + function testOpMinTwoOutputs() external { + checkBadOutputs("_ _: min(1 1);", 2, 1, 2); + } + /// Test the eval of `min` opcode parsed from a string. /// Tests that operands are disallowed. function testOpMinEvalOperandDisallowed() external { diff --git a/test/src/lib/op/math/LibOpSqrt.t.sol b/test/src/lib/op/math/LibOpSqrt.t.sol index 5227b30f4..f544fdd7b 100644 --- a/test/src/lib/op/math/LibOpSqrt.t.sol +++ b/test/src/lib/op/math/LibOpSqrt.t.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {OpTest, IntegrityCheckState, OperandV2, InterpreterState, UnexpectedOperand} from "test/abstract/OpTest.sol"; +import {PowNegativeBase} from "rain.math.float/error/ErrDecimalFloat.sol"; import {LibOpSqrt} from "src/lib/op/math/LibOpSqrt.sol"; import {LibOperand} from "test/lib/operand/LibOperand.sol"; import {LibDecimalFloat, Float} from "rain.math.float/lib/LibDecimalFloat.sol"; @@ -65,6 +66,11 @@ contract LibOpSqrtTest is OpTest { checkBadOutputs("_ _: sqrt(1);", 1, 1, 2); } + /// Test that sqrt of a negative number reverts. + function testOpSqrtEvalNegativeInput() external { + checkUnhappy("_: sqrt(-1);", abi.encodeWithSelector(PowNegativeBase.selector, -1, 0)); + } + /// Test that operand is disallowed. function testOpSqrtEvalOperandDisallowed() external { checkUnhappyParse("_: sqrt<0>(1);", abi.encodeWithSelector(UnexpectedOperand.selector)); diff --git a/test/src/lib/op/math/LibOpSub.t.sol b/test/src/lib/op/math/LibOpSub.t.sol index e1489ee4c..0141f99d0 100644 --- a/test/src/lib/op/math/LibOpSub.t.sol +++ b/test/src/lib/op/math/LibOpSub.t.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {OpTest, IntegrityCheckState, OperandV2} from "test/abstract/OpTest.sol"; +import {UnexpectedOperandValue} from "src/error/ErrParse.sol"; import {LibOpSub} from "src/lib/op/math/LibOpSub.sol"; import {LibOperand} from "test/lib/operand/LibOperand.sol"; import {StackItem} from "rain.interpreter.interface/interface/IInterpreterV4.sol"; @@ -105,4 +106,18 @@ contract LibOpSubTest is OpTest { checkHappy("_: sub(2 1 1);", Float.unwrap(LibDecimalFloat.packLossless(0, -76)), "2 1 1"); checkHappy("_: sub(2 2 0);", Float.unwrap(LibDecimalFloat.packLossless(0, 0)), "2 2 0"); } + + function testOpSubZeroOutputs() external { + checkBadOutputs(": sub(1 1);", 2, 1, 0); + } + + function testOpSubTwoOutputs() external { + checkBadOutputs("_ _: sub(1 1);", 2, 1, 2); + } + + function testOpSubEvalTwoOperandsDisallowed() external { + checkUnhappyParse("_: sub<0 0>(1 1);", abi.encodeWithSelector(UnexpectedOperandValue.selector)); + checkUnhappyParse("_: sub<0 1>(1 1);", abi.encodeWithSelector(UnexpectedOperandValue.selector)); + checkUnhappyParse("_: sub<1 0>(1 1);", abi.encodeWithSelector(UnexpectedOperandValue.selector)); + } } diff --git a/test/src/lib/op/math/uint256/LibOpMaxUint256.t.sol b/test/src/lib/op/math/uint256/LibOpMaxUint256.t.sol index 73bcaae2a..c2ed40a84 100644 --- a/test/src/lib/op/math/uint256/LibOpMaxUint256.t.sol +++ b/test/src/lib/op/math/uint256/LibOpMaxUint256.t.sol @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {OpTest} from "test/abstract/OpTest.sol"; +import {OpTest, UnexpectedOperand} from "test/abstract/OpTest.sol"; import {LibOpMaxUint256} from "src/lib/op/math/uint256/LibOpMaxUint256.sol"; import {IntegrityCheckState, BadOpInputsLength} from "src/lib/integrity/LibIntegrityCheck.sol"; import {OperandV2, StackItem} from "rain.interpreter.interface/interface/IInterpreterV4.sol"; @@ -60,4 +60,9 @@ contract LibOpMaxUint256Test is OpTest { function testOpMaxUint256TwoOutputs() external { checkBadOutputs("_ _: uint256-max-value();", 0, 1, 2); } + + /// Test that operand is disallowed. + function testOpMaxUint256EvalOperandDisallowed() external { + checkUnhappyParse("_: uint256-max-value<0>();", abi.encodeWithSelector(UnexpectedOperand.selector)); + } } diff --git a/test/src/lib/parse/LibParse.namedLHS.t.sol b/test/src/lib/parse/LibParse.namedLHS.t.sol index 7525767b7..602fae681 100644 --- a/test/src/lib/parse/LibParse.namedLHS.t.sol +++ b/test/src/lib/parse/LibParse.namedLHS.t.sol @@ -164,6 +164,67 @@ contract LibParseNamedLHSTest is ParseTest { assertEq(constants[2], Float.unwrap(LibDecimalFloat.packLossless(3, 0))); } + /// Stack name as the sole item on RHS of a line. + function testParseNamedLHSStackNameOnly() external view { + (bytes memory bytecode, bytes32[] memory constants) = + LibParseState.newState("a:1,b:a;", "", "", LibAllStandardOps.literalParserFunctionPointers()).parse(); + assertEq( + bytecode, + // 1 source + hex"01" + // offset 0 + hex"0000" + // 2 ops + hex"02" + // 2 stack allocation + hex"02" + // 0 input + hex"00" + // 2 output + hex"02" + // constant 0 + hex"01100000" + // stack 0 + hex"00100000" + ); + assertEq(constants.length, 1); + assertEq(constants[0], Float.unwrap(LibDecimalFloat.packLossless(1, 0))); + } + + /// Stack name at the last position on RHS after other items. + function testParseNamedLHSStackNameLastPosition() external view { + (bytes memory bytecode, bytes32[] memory constants) = LibParseState.newState( + "a _:1 2,b c:3 a;", "", "", LibAllStandardOps.literalParserFunctionPointers() + ).parse(); + assertEq( + bytecode, + // 1 source + hex"01" + // offset 0 + hex"0000" + // 4 ops + hex"04" + // 4 stack allocation + hex"04" + // 0 input + hex"00" + // 4 output + hex"04" + // constant 0 + hex"01100000" + // constant 1 + hex"01100001" + // constant 2 + hex"01100002" + // stack 0 + hex"00100000" + ); + assertEq(constants.length, 3); + assertEq(constants[0], Float.unwrap(LibDecimalFloat.packLossless(1, 0))); + assertEq(constants[1], Float.unwrap(LibDecimalFloat.packLossless(2, 0))); + assertEq(constants[2], Float.unwrap(LibDecimalFloat.packLossless(3, 0))); + } + /// Duplicate names are disallowed in the same source. function testParseNamedErrorDuplicateSameSource() external { vm.expectRevert(abi.encodeWithSelector(DuplicateLHSItem.selector, 4)); diff --git a/test/src/lib/parse/LibParse.unexpectedRHS.t.sol b/test/src/lib/parse/LibParse.unexpectedRHS.t.sol index 1bee305dc..b6c803735 100644 --- a/test/src/lib/parse/LibParse.unexpectedRHS.t.sol +++ b/test/src/lib/parse/LibParse.unexpectedRHS.t.sol @@ -63,4 +63,12 @@ contract LibParseUnexpectedRHSTest is ParseTest { (bytes memory bytecode, bytes32[] memory constants) = this.parseExternal(s); (bytecode, constants); } + + /// Check the parser reverts when two words appear consecutively without + /// whitespace (yang-state word-word path). + function testParseUnexpectedRHSYangWordWord() external { + vm.expectRevert(abi.encodeWithSelector(UnexpectedRHSChar.selector, 10)); + (bytes memory bytecode, bytes32[] memory constants) = this.parseExternal("_:add(1 2)add(3 4);"); + (bytecode, constants); + } } diff --git a/test/src/lib/parse/LibParseError.t.sol b/test/src/lib/parse/LibParseError.t.sol new file mode 100644 index 000000000..109ef9398 --- /dev/null +++ b/test/src/lib/parse/LibParseError.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; +import {LibParseError} from "src/lib/parse/LibParseError.sol"; +import {LibAllStandardOps} from "src/lib/op/LibAllStandardOps.sol"; + +/// @title LibParseErrorTest +/// @notice Unit tests for parseErrorOffset and handleErrorSelector. +contract LibParseErrorTest is Test { + using LibParseError for ParseState; + + /// parseErrorOffset returns 0 when cursor points to the first byte of data. + function testParseErrorOffsetFirstByte() external pure { + bytes memory data = "hello"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor; + assembly ("memory-safe") { + cursor := add(data, 0x20) + } + assertEq(state.parseErrorOffset(cursor), 0); + } + + /// parseErrorOffset returns data.length - 1 when cursor points to the last byte. + function testParseErrorOffsetLastByte() external pure { + bytes memory data = "hello"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor; + assembly ("memory-safe") { + cursor := add(add(data, 0x20), sub(mload(data), 1)) + } + assertEq(state.parseErrorOffset(cursor), 4); + } + + /// parseErrorOffset works with a fuzzed cursor within data bounds. + function testParseErrorOffsetFuzz(uint8 dataLength, uint8 cursorIndex) external pure { + dataLength = uint8(bound(dataLength, 1, 255)); + cursorIndex = uint8(bound(cursorIndex, 0, dataLength - 1)); + bytes memory data = new bytes(dataLength); + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor; + assembly ("memory-safe") { + cursor := add(add(data, 0x20), cursorIndex) + } + assertEq(state.parseErrorOffset(cursor), cursorIndex); + } + + /// External wrapper for handleErrorSelector so expectRevert works. + function externalHandleErrorSelector(bytes memory data, uint256 cursorOffset, bytes4 errorSelector) external pure { + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor; + assembly ("memory-safe") { + cursor := add(add(data, 0x20), cursorOffset) + } + state.handleErrorSelector(cursor, errorSelector); + } + + /// handleErrorSelector with a non-zero selector reverts with the selector + /// and the cursor offset. + function testHandleErrorSelectorReverts() external { + bytes memory data = "abcdefgh"; + bytes4 selector = bytes4(keccak256("TestError(uint256)")); + vm.expectRevert(abi.encodeWithSelector(selector, 5)); + this.externalHandleErrorSelector(data, 5, selector); + } + + /// handleErrorSelector with a zero selector does not revert. + function testHandleErrorSelectorZeroNoOp() external view { + this.externalHandleErrorSelector("hello", 3, bytes4(0)); + } +} diff --git a/test/src/lib/parse/LibParseInterstitial.t.sol b/test/src/lib/parse/LibParseInterstitial.t.sol index 7668485cc..2eccf4294 100644 --- a/test/src/lib/parse/LibParseInterstitial.t.sol +++ b/test/src/lib/parse/LibParseInterstitial.t.sol @@ -6,7 +6,8 @@ import {Test} from "forge-std/Test.sol"; import {LibBytes, Pointer} from "rain.solmem/lib/LibBytes.sol"; import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; import {LibParseInterstitial} from "src/lib/parse/LibParseInterstitial.sol"; -import {MalformedCommentStart} from "src/error/ErrParse.sol"; +import {FSM_YANG_MASK} from "src/lib/parse/LibParseState.sol"; +import {MalformedCommentStart, UnclosedComment} from "src/error/ErrParse.sol"; /// @title LibParseInterstitialTest /// @notice Tests for LibParseInterstitial. @@ -19,6 +20,8 @@ contract LibParseInterstitialTest is Test { /// UnclosedComment check first. function testMalformedCommentStart(uint8 secondByte) external { vm.assume(secondByte != 0x2A); // not '*' + // Safe: single-byte values fit in bytes1; "*/" fits in bytes2. + //forge-lint: disable-next-line(unsafe-typecast) bytes memory data = abi.encodePacked(bytes1("/"), bytes1(secondByte), bytes2("*/")); vm.expectRevert(abi.encodeWithSelector(MalformedCommentStart.selector, 0)); @@ -32,4 +35,146 @@ contract LibParseInterstitialTest is Test { uint256 cursor = Pointer.unwrap(state.data.dataPointer()); return state.skipComment(cursor, Pointer.unwrap(data.endDataPointer())); } + + /// skipComment with fewer than 4 bytes reverts with UnclosedComment. + function testSkipCommentTooShort() external { + vm.expectRevert(abi.encodeWithSelector(UnclosedComment.selector, 0)); + this.externalSkipComment(bytes("/*")); + } + + /// skipComment with exactly 3 bytes reverts with UnclosedComment. + function testSkipCommentThreeBytes() external { + vm.expectRevert(abi.encodeWithSelector(UnclosedComment.selector, 0)); + this.externalSkipComment(bytes("/* ")); + } + + /// skipComment sets yang mask. + function testSkipCommentSetsYang() external pure { + bytes memory data = "/**/x"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + assertEq(state.fsm & FSM_YANG_MASK, 0, "yang initially clear"); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + cursor = state.skipComment(cursor, end); + assertTrue(state.fsm & FSM_YANG_MASK > 0, "yang set after comment"); + uint256 charAtCursor; + assembly ("memory-safe") { + charAtCursor := byte(0, mload(cursor)) + } + // Safe: "x" is a single ASCII character. + //forge-lint: disable-next-line(unsafe-typecast) + assertEq(charAtCursor, uint256(uint8(bytes1("x"))), "cursor at x"); + } + + /// skipWhitespace clears yang mask and advances cursor. + function testSkipWhitespaceClearsYang() external pure { + bytes memory data = " x"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + state.fsm |= FSM_YANG_MASK; + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + cursor = state.skipWhitespace(cursor, end); + assertEq(state.fsm & FSM_YANG_MASK, 0, "yang cleared"); + uint256 charAtCursor; + assembly ("memory-safe") { + charAtCursor := byte(0, mload(cursor)) + } + // Safe: "x" is a single ASCII character. + //forge-lint: disable-next-line(unsafe-typecast) + assertEq(charAtCursor, uint256(uint8(bytes1("x"))), "cursor at x"); + } + + /// skipWhitespace at end returns cursor unchanged. + function testSkipWhitespaceAtEnd() external pure { + bytes memory data = "x"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 end = Pointer.unwrap(data.endDataPointer()); + uint256 result = state.skipWhitespace(end, end); + assertEq(result, end, "cursor unchanged at end"); + } + + /// parseInterstitial skips mixed whitespace and comments. + function testParseInterstitialMixed() external pure { + bytes memory data = " /* comment */ x"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + cursor = state.parseInterstitial(cursor, end); + uint256 charAtCursor; + assembly ("memory-safe") { + charAtCursor := byte(0, mload(cursor)) + } + // Safe: "x" is a single ASCII character. + //forge-lint: disable-next-line(unsafe-typecast) + assertEq(charAtCursor, uint256(uint8(bytes1("x"))), "cursor at x"); + } + + /// parseInterstitial at end returns cursor unchanged. + function testParseInterstitialAtEnd() external pure { + bytes memory data = "x"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 end = Pointer.unwrap(data.endDataPointer()); + uint256 result = state.parseInterstitial(end, end); + assertEq(result, end, "cursor unchanged at end"); + } + + /// skipWhitespace advances over tab, newline, and carriage return. + function testSkipWhitespaceAllTypes() external pure { + // space, tab, newline, carriage return, then 'x' + bytes memory data = hex"20090a0d78"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + cursor = state.skipWhitespace(cursor, end); + uint256 charAtCursor; + assembly ("memory-safe") { + charAtCursor := byte(0, mload(cursor)) + } + // Safe: "x" is a single ASCII character. + //forge-lint: disable-next-line(unsafe-typecast) + assertEq(charAtCursor, uint256(uint8(bytes1("x"))), "cursor at x after all ws types"); + } + + /// skipComment advances past a comment with content inside. + function testSkipCommentWithContent() external pure { + bytes memory data = "/* hello world */x"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + cursor = state.skipComment(cursor, end); + uint256 charAtCursor; + assembly ("memory-safe") { + charAtCursor := byte(0, mload(cursor)) + } + // Safe: "x" is a single ASCII character. + //forge-lint: disable-next-line(unsafe-typecast) + assertEq(charAtCursor, uint256(uint8(bytes1("x"))), "cursor at x after content comment"); + } + + /// parseInterstitial returns immediately when first character is not + /// whitespace or comment head. + function testParseInterstitialNonInterstitialFirst() external pure { + bytes memory data = "xyz"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + uint256 result = state.parseInterstitial(cursor, end); + assertEq(result, cursor, "cursor unchanged on non-interstitial"); + } + + /// parseInterstitial skips multiple consecutive comments. + function testParseInterstitialMultipleComments() external pure { + bytes memory data = "/* a */ /* b */ /* c */x"; + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + cursor = state.parseInterstitial(cursor, end); + uint256 charAtCursor; + assembly ("memory-safe") { + charAtCursor := byte(0, mload(cursor)) + } + // Safe: "x" is a single ASCII character. + //forge-lint: disable-next-line(unsafe-typecast) + assertEq(charAtCursor, uint256(uint8(bytes1("x"))), "cursor at x after multiple comments"); + } } diff --git a/test/src/lib/parse/LibParseOperand.handleOperand.t.sol b/test/src/lib/parse/LibParseOperand.handleOperand.t.sol new file mode 100644 index 000000000..0029f2012 --- /dev/null +++ b/test/src/lib/parse/LibParseOperand.handleOperand.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibParseOperand, OperandV2} from "src/lib/parse/LibParseOperand.sol"; +import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; +import {LibAllStandardOps} from "src/lib/op/LibAllStandardOps.sol"; +import {UnexpectedOperand, ExpectedOperand} from "src/error/ErrParse.sol"; + +/// @title LibParseOperandHandleOperandTest +/// @notice Direct unit tests for the `handleOperand` dispatch function. +contract LibParseOperandHandleOperandTest is Test { + using LibParseOperand for ParseState; + + function handleOperandExternal(ParseState memory state, uint256 wordIndex) external pure returns (OperandV2) { + return state.handleOperand(wordIndex); + } + + /// Both handleOperandSingleFull (index 1, stack) and + /// handleOperandDisallowed (index 5, bitwise-and) return 0 for empty + /// operand values. + function testHandleOperandDispatchEmptyValues() external pure { + ParseState memory state = LibParseState.newState("", "", "", ""); + state.operandHandlers = LibAllStandardOps.operandHandlerFunctionPointers(); + state.operandValues = new bytes32[](0); + + // Index 1 (stack) -> handleOperandSingleFull -> returns 0. + assertEq(OperandV2.unwrap(state.handleOperand(1)), 0, "stack empty"); + // Index 5 (bitwise-and) -> handleOperandDisallowed -> returns 0. + assertEq(OperandV2.unwrap(state.handleOperand(5)), 0, "bitwise-and empty"); + } + + /// Prove different indices dispatch to different handlers: index 1 (stack, + /// handleOperandSingleFull) accepts a single value and returns it; + /// index 5 (bitwise-and, handleOperandDisallowed) reverts. + function testHandleOperandDispatchDifferentHandlers(uint256 value) external { + value = bound(value, 0, type(uint16).max); + ParseState memory state = LibParseState.newState("", "", "", ""); + state.operandHandlers = LibAllStandardOps.operandHandlerFunctionPointers(); + + bytes32[] memory values = new bytes32[](1); + values[0] = bytes32(value); + state.operandValues = values; + + // Index 1 (stack) -> handleOperandSingleFull -> returns the value. + assertEq(OperandV2.unwrap(state.handleOperand(1)), bytes32(value), "stack single value"); + + // Index 5 (bitwise-and) -> handleOperandDisallowed -> reverts. + vm.expectRevert(abi.encodeWithSelector(UnexpectedOperand.selector)); + this.handleOperandExternal(state, 5); + } + + /// Multiple indices that share the same handler produce the same result. + /// Indices 1 (stack) and 2 (constant) both use handleOperandSingleFull. + function testHandleOperandDispatchSameHandler(uint256 value) external pure { + value = bound(value, 0, type(uint16).max); + ParseState memory state = LibParseState.newState("", "", "", ""); + state.operandHandlers = LibAllStandardOps.operandHandlerFunctionPointers(); + + bytes32[] memory values = new bytes32[](1); + values[0] = bytes32(value); + state.operandValues = values; + + bytes32 expected = bytes32(value); + assertEq(OperandV2.unwrap(state.handleOperand(1)), expected, "stack"); + assertEq(OperandV2.unwrap(state.handleOperand(2)), expected, "constant"); + } + + /// Multiple indices that use handleOperandDisallowed all revert when + /// given a value. + function testHandleOperandDispatchDisallowedMultipleIndices() external { + ParseState memory state = LibParseState.newState("", "", "", ""); + state.operandHandlers = LibAllStandardOps.operandHandlerFunctionPointers(); + + bytes32[] memory values = new bytes32[](1); + values[0] = bytes32(uint256(1)); + state.operandValues = values; + + // Index 5 (bitwise-and), 6 (bitwise-or), 13 (hash) all use + // handleOperandDisallowed. + vm.expectRevert(abi.encodeWithSelector(UnexpectedOperand.selector)); + this.handleOperandExternal(state, 5); + + vm.expectRevert(abi.encodeWithSelector(UnexpectedOperand.selector)); + this.handleOperandExternal(state, 6); + + vm.expectRevert(abi.encodeWithSelector(UnexpectedOperand.selector)); + this.handleOperandExternal(state, 13); + } +} diff --git a/test/src/lib/parse/LibParseOperand.handleOperand8M1M1.t.sol b/test/src/lib/parse/LibParseOperand.handleOperand8M1M1.t.sol index ea18a1185..ddf7c5057 100644 --- a/test/src/lib/parse/LibParseOperand.handleOperand8M1M1.t.sol +++ b/test/src/lib/parse/LibParseOperand.handleOperand8M1M1.t.sol @@ -76,6 +76,22 @@ contract LibParseOperandHandleOperand8M1M1Test is Test { assertEq(OperandV2.unwrap(LibParseOperand.handleOperand8M1M1(values)), bytes32((c << 9) | (b << 8) | a)); } + // If all the values are provided but the first is greater than 1 byte, it + // is an error. + function testHandleOperand8M1M1AllValuesFirstValueTooLarge(int256 a, uint256 b, uint256 c) external { + a = bound(a, int256(uint256(type(uint8).max)) + 1, type(int128).max); + b = bound(b, 0, 1); + c = bound(c, 0, 1); + + bytes32[] memory values = new bytes32[](3); + //forge-lint: disable-next-line(unsafe-typecast) + values[0] = bytes32(uint256(a)); + values[1] = bytes32(b); + values[2] = bytes32(c); + vm.expectRevert(abi.encodeWithSelector(OperandOverflow.selector)); + this.handleOperand8M1M1External(values); + } + // If all the values are provided, the first is 1 byte, the second is 1 bit // but the third is greater than 1 bit, it is an error. function testHandleOperand8M1M1AllValuesThirdValueTooLarge(uint256 a, uint256 b, uint256 c) external { diff --git a/test/src/lib/parse/LibParseOperand.handleOperandM1M1.t.sol b/test/src/lib/parse/LibParseOperand.handleOperandM1M1.t.sol index 958509ef4..cb08176f2 100644 --- a/test/src/lib/parse/LibParseOperand.handleOperandM1M1.t.sol +++ b/test/src/lib/parse/LibParseOperand.handleOperandM1M1.t.sol @@ -60,6 +60,19 @@ contract LibParseOperandHandleOperandM1M1Test is Test { this.handleOperandM1M1External(values); } + // If two values are provided and the first is greater than 1 bit, it is + // an error. + function testHandleOperandM1M1TwoValuesFirstValueTooLarge(uint256 a, uint256 b) external { + a = bound(a, 2, uint256(int256(type(int128).max))); + b = bound(b, 0, 1); + + bytes32[] memory values = new bytes32[](2); + values[0] = bytes32(a); + values[1] = bytes32(b); + vm.expectRevert(abi.encodeWithSelector(OperandOverflow.selector)); + this.handleOperandM1M1External(values); + } + // If more than two values are provided, it is an error. function testHandleOperandM1M1ManyValues(bytes32[] memory values) external { vm.assume(values.length > 2); diff --git a/test/src/lib/parse/LibParseOperand.parseOperand.t.sol b/test/src/lib/parse/LibParseOperand.parseOperand.t.sol index e9fd42d5a..4bc01d7e3 100644 --- a/test/src/lib/parse/LibParseOperand.parseOperand.t.sol +++ b/test/src/lib/parse/LibParseOperand.parseOperand.t.sol @@ -287,4 +287,12 @@ contract LibParseOperandParseOperandTest is Test { vm.expectRevert(abi.encodeWithSelector(UnclosedOperand.selector, 6)); this.checkParsingOperandFromData("<1 2 3;> 6", new bytes32[](0), 0); } + + // Two literals back-to-back without whitespace hit the yang-state + // UnclosedOperand revert at line 111. After parsing `2`, yang is set, + // then `"` is a valid literal head but yang prevents parsing it. + function testParseOperandYangStateLiteralCollision() external { + vm.expectRevert(abi.encodeWithSelector(UnclosedOperand.selector, 4)); + this.checkParsingOperandFromData("<1 2\"hi\">", new bytes32[](0), 0); + } } diff --git a/test/src/lib/parse/LibParsePragma.keyword.t.sol b/test/src/lib/parse/LibParsePragma.keyword.t.sol index 42dda0f61..719c74cef 100644 --- a/test/src/lib/parse/LibParsePragma.keyword.t.sol +++ b/test/src/lib/parse/LibParsePragma.keyword.t.sol @@ -83,6 +83,13 @@ contract LibParsePragmaKeywordTest is Test { assertEq(cursorAfter, cursor); } + /// Input ends exactly at the keyword boundary with no trailing bytes. + /// Hits the `cursor >= end` revert at line 55. + function testPragmaKeywordEndAtKeyword() external { + vm.expectRevert(abi.encodeWithSelector(NoWhitespaceAfterUsingWordsFrom.selector, PRAGMA_KEYWORD_BYTES_LENGTH)); + this.externalParsePragma(string(PRAGMA_KEYWORD_BYTES)); + } + /// Anything that DOES start with the keyword but WITHOUT whitespace should /// error. /// forge-config: default.fuzz.runs = 100 @@ -219,6 +226,59 @@ contract LibParsePragmaKeywordTest is Test { assertEq(deref, 0); } + /// Two pragmas in sequence. The first parsePragma call stops at the second + /// keyword. Calling parsePragma again from that cursor parses the second. + function testPragmaKeywordTwoSequentialPragmas() external view { + address addr1 = 0x1234567890123456789012345678901234567890; + address addr2 = 0x0987654321098765432109876543210987654321; + string memory str = + string.concat("using-words-from ", addr1.toHexString(), " using-words-from ", addr2.toHexString()); + ParseState memory state = + LibParseState.newState(bytes(str), "", "", LibAllStandardOps.literalParserFunctionPointers()); + uint256 cursor = Pointer.unwrap(bytes(str).dataPointer()); + uint256 end = Pointer.unwrap(bytes(str).endDataPointer()); + + // First pragma: "using-words-from " (17) + 42-char address + " " interstitial = 60. + uint256 cursor2 = state.parsePragma(cursor, end); + assertEq(cursor2, cursor + 60, "first pragma cursor"); + + // Second pragma parses the second address. + uint256 cursor3 = state.parsePragma(cursor2, end); + assertEq(cursor3, end, "should reach end"); + + // Both sub parsers should be in the linked list. + bytes32 deref = state.subParsers; + assertEq(uint160(uint256(deref)), uint160(addr2), "second address"); + uint256 pointer = uint256(deref) >> 0xF0; + assembly ("memory-safe") { + deref := mload(pointer) + } + assertEq(uint160(uint256(deref)), uint160(addr1), "first address"); + // The list must terminate: dereferencing the first node's next pointer + // yields zero (the initial empty subParsers value). + uint256 nextPointer = uint256(deref) >> 0xF0; + bytes32 nextDeref; + assembly ("memory-safe") { + nextDeref := mload(nextPointer) + } + assertEq(uint256(nextDeref), 0, "list must terminate after first address"); + } + + /// Comments between addresses in a pragma are handled by parseInterstitial. + function testPragmaKeywordCommentBetweenAddresses() external view { + address addr1 = 0x1234567890123456789012345678901234567890; + address addr2 = 0x0987654321098765432109876543210987654321; + string memory str = + string.concat("using-words-from ", addr1.toHexString(), " /* a comment */ ", addr2.toHexString()); + + address[] memory values = new address[](2); + values[0] = addr1; + values[1] = addr2; + + // "using-words-from " (17) + 42 + " /* a comment */ " (17) + 42 = 118 + checkPragmaParsing(str, 118, values, "comment between addresses"); + } + /// Test a specific string. function testPragmaKeywordParseSubParserSpecificStrings() external view { string memory str = diff --git a/test/src/lib/parse/LibParseStackName.t.sol b/test/src/lib/parse/LibParseStackName.t.sol index ccffcffce..9930c0b65 100644 --- a/test/src/lib/parse/LibParseStackName.t.sol +++ b/test/src/lib/parse/LibParseStackName.t.sol @@ -75,6 +75,71 @@ contract LibParseStackNameTest is Test { assertEq(index, 0); } + /// Look up a word that was never pushed on a populated list. + function testStackNameNegativeLookup(ParseState memory state, bytes32 word1, bytes32 word2) external pure { + vm.assume(word1 != word2); + state.lineTracker = 0; + state.topLevel1 = 0; + state.stackNames = 0; + state.stackNameBloom = 0; + + LibParseStackName.pushStackName(state, word1); + state.lineTracker = 1; + state.topLevel1 = 1; + + (bool exists, uint256 index) = LibParseStackName.stackNameIndex(state, word2); + assertFalse(exists, "word2 should not exist"); + assertEq(index, 0, "index should be 0 on miss"); + } + + /// Construct two words that share the same bloom bit (low 8 bits of the + /// shifted keccak fingerprint) to force a bloom false positive. Push only + /// word A, look up word B — the bloom filter says "maybe" but the + /// linked-list traversal finds no match. + function testStackNameBloomFalsePositive() external pure { + // Find two words whose fingerprints share the same low 8 bits. + bytes32 wordA = bytes32(uint256(0)); + bytes32 wordB; + uint256 targetBloomBit; + assembly ("memory-safe") { + mstore(0, wordA) + let fpA := shr(0x20, keccak256(0, 0x20)) + targetBloomBit := and(fpA, 0xFF) + } + // Brute-force a second word with the same bloom bit. + for (uint256 i = 1; i < 1000; i++) { + bytes32 candidate = bytes32(i); + uint256 bloomBit; + assembly ("memory-safe") { + mstore(0, candidate) + bloomBit := and(shr(0x20, keccak256(0, 0x20)), 0xFF) + } + if (bloomBit == targetBloomBit) { + wordB = candidate; + break; + } + } + // Sanity: we found a collision. + assertTrue(wordB != bytes32(0), "should find bloom collision"); + assertTrue(wordA != wordB, "words must differ"); + + ParseState memory state; + state.lineTracker = 0; + state.topLevel1 = 0; + state.stackNames = 0; + state.stackNameBloom = 0; + + // Push wordA only. + LibParseStackName.pushStackName(state, wordA); + state.lineTracker = 1; + state.topLevel1 = 1; + + // Look up wordB — bloom hit (same bit) but fingerprint mismatch. + (bool exists, uint256 index) = LibParseStackName.stackNameIndex(state, wordB); + assertFalse(exists, "bloom false positive: should not exist"); + assertEq(index, 0, "bloom false positive: index should be 0"); + } + /// Test that we can push and retrieve many stack names. function testPushAndRetrieveStackNameMany(ParseState memory state, uint256 n) external pure { n = bound(n, 1, 100); diff --git a/test/src/lib/parse/LibParseState.buildBytecode.t.sol b/test/src/lib/parse/LibParseState.buildBytecode.t.sol index 46ee0bfd2..a985ed979 100644 --- a/test/src/lib/parse/LibParseState.buildBytecode.t.sol +++ b/test/src/lib/parse/LibParseState.buildBytecode.t.sol @@ -55,6 +55,8 @@ contract LibParseStateBuildBytecodeTest is Test { uint256 nOps = bound(uint256(keccak256(abi.encode(opsPerSourceSeed, s))), 1, 20); expectedOps[s] = nOps; for (uint256 j = 0; j < nOps; j++) { + // Safe: j % 256 fits in uint8 by definition. + //forge-lint: disable-next-line(unsafe-typecast) state.pushOpToSource(uint8(j % 256), OperandV2.wrap(bytes32(uint256(j)))); } state.endSource(); diff --git a/test/src/lib/parse/LibParseState.buildConstants.t.sol b/test/src/lib/parse/LibParseState.buildConstants.t.sol new file mode 100644 index 000000000..128a40bf8 --- /dev/null +++ b/test/src/lib/parse/LibParseState.buildConstants.t.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; + +/// @title LibParseStateBuildConstantsTest +/// @notice Direct unit tests for buildConstants. +contract LibParseStateBuildConstantsTest is Test { + using LibParseState for ParseState; + + /// Empty constants list produces a zero-length array. + function testBuildConstantsEmpty() external pure { + ParseState memory state = LibParseState.newState("", "", "", ""); + bytes32[] memory constants = state.buildConstants(); + assertEq(constants.length, 0, "empty constants length"); + } + + /// Single constant is returned correctly. + function testBuildConstantsSingle(bytes32 value) external pure { + ParseState memory state = LibParseState.newState("", "", "", ""); + state.pushConstantValue(value); + bytes32[] memory constants = state.buildConstants(); + assertEq(constants.length, 1, "single constants length"); + assertEq(constants[0], value, "single constant value"); + } + + /// Multiple constants are returned in push order (reversed from linked + /// list traversal order). + function testBuildConstantsMultiple() external pure { + ParseState memory state = LibParseState.newState("", "", "", ""); + state.pushConstantValue(bytes32(uint256(0xaa))); + state.pushConstantValue(bytes32(uint256(0xbb))); + state.pushConstantValue(bytes32(uint256(0xcc))); + + bytes32[] memory constants = state.buildConstants(); + assertEq(constants.length, 3, "length"); + assertEq(constants[0], bytes32(uint256(0xaa)), "first"); + assertEq(constants[1], bytes32(uint256(0xbb)), "second"); + assertEq(constants[2], bytes32(uint256(0xcc)), "third"); + } + + /// Fuzz: push N constants, verify buildConstants returns them in push + /// order. + function testBuildConstantsFuzz(bytes32[] memory values) external pure { + vm.assume(values.length > 0 && values.length <= 100); + ParseState memory state = LibParseState.newState("", "", "", ""); + + for (uint256 i = 0; i < values.length; i++) { + state.pushConstantValue(values[i]); + } + + bytes32[] memory constants = state.buildConstants(); + assertEq(constants.length, values.length, "length mismatch"); + for (uint256 i = 0; i < values.length; i++) { + assertEq(constants[i], values[i], "value mismatch"); + } + } +} diff --git a/test/src/lib/parse/LibParseState.endSource.t.sol b/test/src/lib/parse/LibParseState.endSource.t.sol index 8e20f8782..4ecd0693d 100644 --- a/test/src/lib/parse/LibParseState.endSource.t.sol +++ b/test/src/lib/parse/LibParseState.endSource.t.sol @@ -105,6 +105,8 @@ contract LibParseStateEndSourceTest is Test { ParseState memory state = LibParseState.newState("", "", "", ""); for (uint256 i = 0; i < opCount; i++) { + // Safe: i % 256 fits in uint8 by definition. + //forge-lint: disable-next-line(unsafe-typecast) state.pushOpToSource(uint8(i % 256), OperandV2.wrap(bytes32(uint256(i)))); } state.endSource(); diff --git a/test/src/lib/parse/LibParseState.pushLiteral.t.sol b/test/src/lib/parse/LibParseState.pushLiteral.t.sol new file mode 100644 index 000000000..34bdc1067 --- /dev/null +++ b/test/src/lib/parse/LibParseState.pushLiteral.t.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; +import {LibAllStandardOps} from "src/lib/op/LibAllStandardOps.sol"; +import {LibBytes, Pointer} from "rain.solmem/lib/LibBytes.sol"; +import {UnsupportedLiteralType} from "src/error/ErrParse.sol"; + +/// @title LibParseStatePushLiteralTest +/// @notice Direct unit tests for pushLiteral. +contract LibParseStatePushLiteralTest is Test { + using LibParseState for ParseState; + using LibBytes for bytes; + + /// Helper: creates a parse state initialised for literal parsing. + function buildState(bytes memory data) internal pure returns (ParseState memory) { + return LibParseState.newState(data, "", "", LibAllStandardOps.literalParserFunctionPointers()); + } + + /// External wrapper so reverts can be caught via expectRevert. + function pushLiteralExternal(bytes memory data, uint256 cursorOffset) external view returns (uint256) { + ParseState memory state = buildState(data); + uint256 cursor = Pointer.unwrap(data.dataPointer()) + cursorOffset; + uint256 end = Pointer.unwrap(data.endDataPointer()); + return state.pushLiteral(cursor, end); + } + + /// A single hex literal should push one constant and advance the cursor. + function testPushLiteralSingleHex() external view { + bytes memory data = bytes("0xff"); + ParseState memory state = buildState(data); + uint256 cursor = Pointer.unwrap(data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + + uint256 cursorAfter = state.pushLiteral(cursor, end); + + // Cursor should advance past the entire literal. + assertEq(cursorAfter, end, "cursor should reach end"); + + // One constant should be in the linked list. + assertEq(state.constantsBuilder & 0xFFFF, 1, "constants height"); + + // The constant value should be 0xff. + uint256 headPtr = state.constantsBuilder >> 0x10; + bytes32 value; + assembly ("memory-safe") { + value := mload(add(headPtr, 0x20)) + } + assertEq(value, bytes32(uint256(0xff)), "constant value"); + + // Bloom should be set. + assertTrue(state.constantsBloom != 0, "bloom should be set"); + } + + /// A single decimal literal should push one constant. + function testPushLiteralSingleDecimal() external view { + bytes memory data = bytes("42e0"); + ParseState memory state = buildState(data); + uint256 cursor = Pointer.unwrap(data.dataPointer()); + uint256 end = Pointer.unwrap(data.endDataPointer()); + + uint256 cursorAfter = state.pushLiteral(cursor, end); + + assertEq(cursorAfter, end, "cursor should reach end"); + assertEq(state.constantsBuilder & 0xFFFF, 1, "constants height"); + } + + /// Two identical hex literals should deduplicate: height stays 1. + function testPushLiteralDuplicate() external view { + // State persists between calls because ParseState is a memory struct. + bytes memory data1 = bytes("0xff"); + bytes memory data2 = bytes("0xff"); + + ParseState memory state = LibParseState.newState("", "", "", LibAllStandardOps.literalParserFunctionPointers()); + + // Push first literal. + { + uint256 cursor1 = Pointer.unwrap(data1.dataPointer()); + uint256 end1 = Pointer.unwrap(data1.endDataPointer()); + state.pushLiteral(cursor1, end1); + } + assertEq(state.constantsBuilder & 0xFFFF, 1, "height after first"); + + // Push second identical literal. + { + uint256 cursor2 = Pointer.unwrap(data2.dataPointer()); + uint256 end2 = Pointer.unwrap(data2.endDataPointer()); + state.pushLiteral(cursor2, end2); + } + // Height should still be 1 because the duplicate was detected. + assertEq(state.constantsBuilder & 0xFFFF, 1, "height after duplicate"); + } + + /// Two different literals should both be pushed: height becomes 2. + function testPushLiteralTwoDifferent() external view { + bytes memory data1 = bytes("0xaa"); + bytes memory data2 = bytes("0xbb"); + + ParseState memory state = LibParseState.newState("", "", "", LibAllStandardOps.literalParserFunctionPointers()); + + { + uint256 cursor = Pointer.unwrap(data1.dataPointer()); + uint256 end = Pointer.unwrap(data1.endDataPointer()); + state.pushLiteral(cursor, end); + } + assertEq(state.constantsBuilder & 0xFFFF, 1, "height after first"); + + { + uint256 cursor = Pointer.unwrap(data2.dataPointer()); + uint256 end = Pointer.unwrap(data2.endDataPointer()); + state.pushLiteral(cursor, end); + } + assertEq(state.constantsBuilder & 0xFFFF, 2, "height after second"); + + // Verify both values are in the linked list (LIFO order). + uint256 headPtr = state.constantsBuilder >> 0x10; + bytes32 val2; + uint256 nextPtr; + assembly ("memory-safe") { + val2 := mload(add(headPtr, 0x20)) + nextPtr := mload(headPtr) + } + assertEq(val2, bytes32(uint256(0xbb)), "second value (head)"); + + bytes32 val1; + assembly ("memory-safe") { + val1 := mload(add(nextPtr, 0x20)) + } + assertEq(val1, bytes32(uint256(0xaa)), "first value (tail)"); + } + + /// Unrecognized literal type should revert with UnsupportedLiteralType. + function testPushLiteralUnsupported() external { + // 'z' is not a valid literal start character. + vm.expectRevert(abi.encodeWithSelector(UnsupportedLiteralType.selector, 0)); + this.pushLiteralExternal(bytes("zzz"), 0); + } +} diff --git a/test/src/lib/parse/LibParseState.pushOpToSource.t.sol b/test/src/lib/parse/LibParseState.pushOpToSource.t.sol index de97f0da1..4aacdbcd7 100644 --- a/test/src/lib/parse/LibParseState.pushOpToSource.t.sol +++ b/test/src/lib/parse/LibParseState.pushOpToSource.t.sol @@ -90,6 +90,8 @@ contract LibParseStatePushOpToSourceTest is Test { uint256 initialPtr = state.activeSourcePtr; for (uint256 i = 0; i < 7; i++) { + // Safe: i < 7, fits in uint8. + //forge-lint: disable-next-line(unsafe-typecast) state.pushOpToSource(uint8(i), OperandV2.wrap(bytes32(0))); } diff --git a/test/src/lib/parse/LibSubParse.constantAccumulation.t.sol b/test/src/lib/parse/LibSubParse.constantAccumulation.t.sol new file mode 100644 index 000000000..4dde006d9 --- /dev/null +++ b/test/src/lib/parse/LibSubParse.constantAccumulation.t.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; +import {LibParse} from "src/lib/parse/LibParse.sol"; +import {LibMetaFixture} from "test/lib/parse/LibMetaFixture.sol"; +import {ISubParserV4} from "rain.interpreter.interface/interface/ISubParserV4.sol"; +import {IERC165} from "openzeppelin-contracts/contracts/utils/introspection/IERC165.sol"; +import {OPCODE_CONSTANT} from "rain.interpreter.interface/interface/IInterpreterV4.sol"; +import {LibBytecode, Pointer} from "rain.interpreter.interface/lib/bytecode/LibBytecode.sol"; +import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; + +/// @dev A sub parser that resolves any word by returning a constant opcode +/// with a known constant value. Each call returns exactly one constant. +contract ConstantReturningSubParser is ISubParserV4, IERC165 { + bytes32 public constant RETURN_VALUE = bytes32(uint256(0xDEADBEEF)); + + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(ISubParserV4).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function subParseLiteral2(bytes calldata) external pure override returns (bool, bytes32) { + return (false, 0); + } + + /// @notice Returns a constant opcode pointing at the current constants + /// height from the header, with a single constant value. + function subParseWord2(bytes calldata data) external pure override returns (bool, bytes memory, bytes32[] memory) { + // Extract constantsHeight from header (first 2 bytes). + uint256 constantsHeight = uint256(uint16(bytes2(data[0:2]))); + + // Build 4-byte constant opcode: [OPCODE_CONSTANT][IO=0x10][operand=constantsHeight] + bytes memory bytecode = new bytes(4); + // Safe: opcode constants and IO byte are small known values that fit + // in uint8/bytes1. + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[0] = bytes1(uint8(OPCODE_CONSTANT)); + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[1] = bytes1(uint8(0x10)); // 0 inputs, 1 output + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[2] = bytes1(uint8(constantsHeight >> 8)); + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[3] = bytes1(uint8(constantsHeight)); + + bytes32[] memory constants = new bytes32[](1); + constants[0] = RETURN_VALUE; + + return (true, bytecode, constants); + } +} + +/// @dev A sub parser that returns multiple constants per word resolution. +contract MultiConstantSubParser is ISubParserV4, IERC165 { + bytes32 public constant VALUE_A = bytes32(uint256(0xAAAA)); + bytes32 public constant VALUE_B = bytes32(uint256(0xBBBB)); + + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(ISubParserV4).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function subParseLiteral2(bytes calldata) external pure override returns (bool, bytes32) { + return (false, 0); + } + + /// @notice Returns a constant opcode with two constants. The first constant + /// is used as the operand target; the second is an extra accumulation. + function subParseWord2(bytes calldata data) external pure override returns (bool, bytes memory, bytes32[] memory) { + uint256 constantsHeight = uint256(uint16(bytes2(data[0:2]))); + + bytes memory bytecode = new bytes(4); + // Safe: opcode constants and IO byte are small known values that fit + // in uint8/bytes1. + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[0] = bytes1(uint8(OPCODE_CONSTANT)); + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[1] = bytes1(uint8(0x10)); + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[2] = bytes1(uint8(constantsHeight >> 8)); + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[3] = bytes1(uint8(constantsHeight)); + + bytes32[] memory constants = new bytes32[](2); + constants[0] = VALUE_A; + constants[1] = VALUE_B; + + return (true, bytecode, constants); + } +} + +/// @title LibSubParseConstantAccumulationTest +/// @notice Tests that constants returned by sub parsers during word resolution +/// are correctly accumulated into the final constants array at the right +/// indices. This addresses finding A44-8: the existing `badSubParserResult` +/// test returns empty constants arrays, so constant accumulation from sub +/// parsers was never verified. +contract LibSubParseConstantAccumulationTest is Test { + using LibParseState for ParseState; + using LibParse for ParseState; + using Strings for address; + + /// @notice A single unknown word resolved by a sub parser that returns one + /// constant. The constant must appear in the final constants array. + function testSubParserSingleConstantAccumulation() external { + ConstantReturningSubParser sub = new ConstantReturningSubParser(); + string memory src = string.concat("using-words-from ", address(sub).toHexString(), " _: some-word();"); + + ParseState memory state = LibMetaFixture.newState(src); + (, bytes32[] memory constants) = state.parse(); + + // The sub parser returns exactly 1 constant per word resolution. + assertEq(constants.length, 1, "Expected 1 constant from sub parser"); + assertEq(constants[0], ConstantReturningSubParser(sub).RETURN_VALUE()); + } + + /// @notice Two unknown words resolved by the same sub parser. Each returns + /// one constant. The final constants array must contain both values in + /// the correct order. + function testSubParserTwoWordsConstantAccumulation() external { + ConstantReturningSubParser sub = new ConstantReturningSubParser(); + string memory src = + string.concat("using-words-from ", address(sub).toHexString(), " _ _: some-word() another-word();"); + + ParseState memory state = LibMetaFixture.newState(src); + (, bytes32[] memory constants) = state.parse(); + + // Both words return the same constant value, but each call adds a new + // constant. Due to deduplication in the constants builder, identical + // values may be deduplicated to a single entry. + // The key assertion: at least one constant is present and has the + // expected value. + assertTrue(constants.length >= 1, "Expected at least 1 constant"); + assertEq(constants[0], ConstantReturningSubParser(sub).RETURN_VALUE()); + } + + /// @notice A sub parser that returns multiple constants per word. All + /// constants must be accumulated. + function testSubParserMultiConstantAccumulation() external { + MultiConstantSubParser sub = new MultiConstantSubParser(); + string memory src = string.concat("using-words-from ", address(sub).toHexString(), " _: multi-word();"); + + ParseState memory state = LibMetaFixture.newState(src); + (, bytes32[] memory constants) = state.parse(); + + // The sub parser returns 2 constants per word. + assertEq(constants.length, 2, "Expected 2 constants from multi-constant sub parser"); + assertEq(constants[0], MultiConstantSubParser(sub).VALUE_A()); + assertEq(constants[1], MultiConstantSubParser(sub).VALUE_B()); + } + + /// @notice Constants from sub parsers are appended after any constants + /// already in the parse state (e.g. from literals). The bytecode for the + /// sub-parsed word should reference the correct index. + function testSubParserConstantIndexAfterLiteral() external { + ConstantReturningSubParser sub = new ConstantReturningSubParser(); + // "1e0" is a literal that becomes constant[0]. + // "some-word" should get constant[1]. + string memory src = string.concat("using-words-from ", address(sub).toHexString(), " _ _: 1e0 some-word();"); + + (bytes memory bytecode, bytes32[] memory constants) = LibMetaFixture.newState(src).parse(); + + // There should be at least 2 constants: one from the literal, one from + // the sub parser. + assertTrue(constants.length >= 2, "Expected at least 2 constants"); + + // The sub parser constant should be at some index in the array. + bool foundSubParserConstant = false; + for (uint256 i = 0; i < constants.length; i++) { + if (constants[i] == ConstantReturningSubParser(sub).RETURN_VALUE()) { + foundSubParserConstant = true; + break; + } + } + assertTrue(foundSubParserConstant, "Sub parser constant not found in constants array"); + + // Verify the bytecode references valid constant indices. + // Source 0 should have 2 ops, both OPCODE_CONSTANT. + uint256 opsCount = LibBytecode.sourceOpsCount(bytecode, 0); + assertEq(opsCount, 2, "Expected 2 ops in source 0"); + + // Read the operands of both constant ops to verify they reference + // valid indices. + Pointer sourcePtr = LibBytecode.sourcePointer(bytecode, 0); + uint256 cursor = Pointer.unwrap(sourcePtr) + 4; // skip 4-byte source prefix + for (uint256 i = 0; i < opsCount; i++) { + uint8 opcode; + uint16 operand; + assembly ("memory-safe") { + let word := mload(cursor) + opcode := byte(0, word) + // Operand is bytes 2-3 of the 4-byte op (big-endian uint16). + operand := and(shr(0xe0, word), 0xFFFF) + } + assertEq(uint256(opcode), OPCODE_CONSTANT, "Expected OPCODE_CONSTANT"); + assertTrue(operand < constants.length, "Constant index out of bounds"); + cursor += 4; + } + } +} diff --git a/test/src/lib/parse/LibSubParse.consumeSubParseLiteralInputData.t.sol b/test/src/lib/parse/LibSubParse.consumeSubParseLiteralInputData.t.sol new file mode 100644 index 000000000..7ff6ae937 --- /dev/null +++ b/test/src/lib/parse/LibSubParse.consumeSubParseLiteralInputData.t.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibSubParse} from "src/lib/parse/LibSubParse.sol"; + +/// @title LibSubParseConsumeSubParseLiteralInputDataTest +/// @notice Direct unit tests for `LibSubParse.consumeSubParseLiteralInputData`. +/// This function unpacks the dispatch and body memory region pointers from an +/// encoded `bytes` payload. The format is: +/// [dispatchLength:2 bytes][dispatch:dispatchLength bytes][body:remaining bytes] +/// It returns memory pointers (dispatchStart, bodyStart, bodyEnd) that +/// partition the data into dispatch and body regions. +contract LibSubParseConsumeSubParseLiteralInputDataTest is Test { + /// @notice Build encoded literal input data from dispatch and body. + function buildLiteralData(bytes memory dispatch, bytes memory body) internal pure returns (bytes memory) { + return bytes.concat(bytes2(uint16(dispatch.length)), dispatch, body); + } + + /// @notice Basic: dispatch and body are correctly partitioned. + function testConsumeLiteralInputDataBasic() external pure { + bytes memory dispatch = bytes("foo"); + bytes memory body = bytes("bar"); + bytes memory data = buildLiteralData(dispatch, body); + + (uint256 dispatchStart, uint256 bodyStart, uint256 bodyEnd) = LibSubParse.consumeSubParseLiteralInputData(data); + + // Dispatch region length should be 3. + assertEq(bodyStart - dispatchStart, dispatch.length); + // Body region length should be 3. + assertEq(bodyEnd - bodyStart, body.length); + // Total data region should be dispatch + body. + assertEq(bodyEnd - dispatchStart, dispatch.length + body.length); + } + + /// @notice Verify the actual bytes in the dispatch region by reading from + /// memory. + function testConsumeLiteralInputDataDispatchContent() external pure { + bytes memory dispatch = bytes("abc"); + bytes memory body = bytes("xyz"); + bytes memory data = buildLiteralData(dispatch, body); + + (uint256 dispatchStart, uint256 bodyStart,) = LibSubParse.consumeSubParseLiteralInputData(data); + + // Read dispatch bytes from memory and compare. + uint256 dispatchLength = bodyStart - dispatchStart; + bytes memory readDispatch = new bytes(dispatchLength); + assembly ("memory-safe") { + let src := dispatchStart + let dst := add(readDispatch, 0x20) + for { let i := 0 } lt(i, dispatchLength) { i := add(i, 1) } { + mstore8(add(dst, i), byte(0, mload(add(src, i)))) + } + } + assertEq(keccak256(readDispatch), keccak256(dispatch)); + } + + /// @notice Verify the actual bytes in the body region by reading from + /// memory. + function testConsumeLiteralInputDataBodyContent() external pure { + bytes memory dispatch = bytes("abc"); + bytes memory body = bytes("xyz"); + bytes memory data = buildLiteralData(dispatch, body); + + (, uint256 bodyStart, uint256 bodyEnd) = LibSubParse.consumeSubParseLiteralInputData(data); + + // Read body bytes from memory and compare. + uint256 bodyLength = bodyEnd - bodyStart; + bytes memory readBody = new bytes(bodyLength); + assembly ("memory-safe") { + let src := bodyStart + let dst := add(readBody, 0x20) + for { let i := 0 } lt(i, bodyLength) { i := add(i, 1) } { + mstore8(add(dst, i), byte(0, mload(add(src, i)))) + } + } + assertEq(keccak256(readBody), keccak256(body)); + } + + /// @notice Empty body: body region has zero length. + function testConsumeLiteralInputDataEmptyBody() external pure { + bytes memory dispatch = bytes("dispatch"); + bytes memory body = bytes(""); + bytes memory data = buildLiteralData(dispatch, body); + + (uint256 dispatchStart, uint256 bodyStart, uint256 bodyEnd) = LibSubParse.consumeSubParseLiteralInputData(data); + + assertEq(bodyStart - dispatchStart, dispatch.length); + assertEq(bodyEnd - bodyStart, 0); + } + + /// @notice Single-byte dispatch, empty body. + function testConsumeLiteralInputDataMinimal() external pure { + bytes memory dispatch = bytes("x"); + bytes memory body = bytes(""); + bytes memory data = buildLiteralData(dispatch, body); + + (uint256 dispatchStart, uint256 bodyStart, uint256 bodyEnd) = LibSubParse.consumeSubParseLiteralInputData(data); + + assertEq(bodyStart - dispatchStart, 1); + assertEq(bodyEnd - bodyStart, 0); + } + + /// @notice Fuzz: dispatch and body lengths are preserved across arbitrary + /// inputs. + function testConsumeLiteralInputDataFuzz(bytes memory dispatch, bytes memory body) external pure { + // Bound dispatch length to valid uint16 range and keep reasonable. + vm.assume(dispatch.length <= 1000); + vm.assume(body.length <= 1000); + vm.assume(dispatch.length > 0); + + bytes memory data = buildLiteralData(dispatch, body); + + (uint256 dispatchStart, uint256 bodyStart, uint256 bodyEnd) = LibSubParse.consumeSubParseLiteralInputData(data); + + assertEq(bodyStart - dispatchStart, dispatch.length); + assertEq(bodyEnd - bodyStart, body.length); + } + + /// @notice Roundtrip: build data with buildLiteralData, unpack with + /// consumeSubParseLiteralInputData, and verify both dispatch and body + /// content are preserved byte-for-byte. + function testConsumeLiteralInputDataRoundtrip() external pure { + bytes memory dispatch = bytes("hello"); + bytes memory body = bytes("world"); + bytes memory data = buildLiteralData(dispatch, body); + + (uint256 dispatchStart, uint256 bodyStart, uint256 bodyEnd) = LibSubParse.consumeSubParseLiteralInputData(data); + + uint256 dispatchLength = dispatch.length; + uint256 bodyLength = body.length; + + assertEq(bodyStart - dispatchStart, dispatchLength); + assertEq(bodyEnd - bodyStart, bodyLength); + + // Verify content by reading from the returned memory pointers. + bytes memory readDispatch = new bytes(dispatchLength); + bytes memory readBody = new bytes(bodyLength); + assembly ("memory-safe") { + let src := dispatchStart + let dst := add(readDispatch, 0x20) + for { let i := 0 } lt(i, dispatchLength) { i := add(i, 1) } { + mstore8(add(dst, i), byte(0, mload(add(src, i)))) + } + + src := bodyStart + dst := add(readBody, 0x20) + for { let i := 0 } lt(i, bodyLength) { i := add(i, 1) } { + mstore8(add(dst, i), byte(0, mload(add(src, i)))) + } + } + assertEq(keccak256(readDispatch), keccak256(dispatch)); + assertEq(keccak256(readBody), keccak256(body)); + } +} diff --git a/test/src/lib/parse/LibSubParse.consumeSubParseWordInputData.t.sol b/test/src/lib/parse/LibSubParse.consumeSubParseWordInputData.t.sol new file mode 100644 index 000000000..7ecd1dacc --- /dev/null +++ b/test/src/lib/parse/LibSubParse.consumeSubParseWordInputData.t.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibSubParse} from "src/lib/parse/LibSubParse.sol"; +import {ParseState} from "src/lib/parse/LibParseState.sol"; + +/// @title LibSubParseConsumeSubParseWordInputDataTest +/// @notice Direct unit tests for `LibSubParse.consumeSubParseWordInputData`. +/// This function unpacks a sub-parse header from encoded `bytes` input, +/// extracting the constants height, IO byte, and constructing a fresh +/// `ParseState` from the remaining word string and provided meta/operand +/// handler bytes. +/// +/// The input data format is: +/// [constantsHeight:2 bytes][ioByte:1 byte][wordLength:2 bytes][wordData:wordLength bytes][operandValues...] +contract LibSubParseConsumeSubParseWordInputDataTest is Test { + /// @notice Build sub-parse word input data from components. + /// @param constantsHeight The constants height (2 bytes). + /// @param ioByte The IO byte (1 byte). + /// @param word The word string. + /// @param operandValues The operand values array (encoded as 32-byte words + /// with a leading length word). + function buildInputData(uint16 constantsHeight, uint8 ioByte, bytes memory word, bytes32[] memory operandValues) + internal + pure + returns (bytes memory) + { + // Encode the operand values as raw memory: length word + value words. + // Cannot alias bytes32[] as bytes directly because the length field + // semantics differ (element count vs byte count). Encode manually. + bytes memory operandBytes = new bytes(32 + operandValues.length * 32); + assembly ("memory-safe") { + // Write the element count as the first 32 bytes (matches bytes32[] ABI). + mstore(add(operandBytes, 0x20), mload(operandValues)) + // Copy the value words. + for { let i := 0 } lt(i, mload(operandValues)) { i := add(i, 1) } { + mstore(add(operandBytes, add(0x40, mul(i, 0x20))), mload(add(operandValues, add(0x20, mul(i, 0x20))))) + } + } + return bytes.concat(bytes2(constantsHeight), bytes1(ioByte), bytes2(uint16(word.length)), word, operandBytes); + } + + /// @notice Basic happy path: extract constants height, IO byte, and word + /// from a well-formed input. + function testConsumeSubParseWordInputDataBasic() external pure { + bytes memory word = bytes("hello"); + bytes32[] memory operandValues = new bytes32[](0); + bytes memory data = buildInputData(42, 0x31, word, operandValues); + bytes memory meta = hex"aabb"; + bytes memory operandHandlers = hex"ccdd"; + + (uint256 constantsHeight, uint256 ioByte, ParseState memory state) = + LibSubParse.consumeSubParseWordInputData(data, meta, operandHandlers); + + assertEq(constantsHeight, 42); + assertEq(ioByte, 0x31); + // The state's data should be the word. + assertEq(keccak256(state.data), keccak256(word)); + // The state's meta should be what we passed. + assertEq(keccak256(state.meta), keccak256(meta)); + // The state's operandHandlers should be what we passed. + assertEq(keccak256(state.operandHandlers), keccak256(operandHandlers)); + } + + /// @notice Fuzz: constants height is correctly extracted across the full + /// uint16 range. + function testConsumeSubParseWordInputDataFuzzConstantsHeight(uint16 constantsHeight) external pure { + bytes memory word = bytes("w"); + bytes32[] memory operandValues = new bytes32[](0); + bytes memory data = buildInputData(constantsHeight, 0x00, word, operandValues); + + (uint256 extractedHeight,,) = LibSubParse.consumeSubParseWordInputData(data, "", ""); + assertEq(extractedHeight, uint256(constantsHeight)); + } + + /// @notice Fuzz: IO byte is correctly extracted across the full uint8 + /// range. + function testConsumeSubParseWordInputDataFuzzIOByte(uint8 ioByte) external pure { + bytes memory word = bytes("w"); + bytes32[] memory operandValues = new bytes32[](0); + bytes memory data = buildInputData(0, ioByte, word, operandValues); + + (, uint256 extractedIOByte,) = LibSubParse.consumeSubParseWordInputData(data, "", ""); + assertEq(extractedIOByte, uint256(ioByte)); + } + + /// @notice Maximum constants height (0xFFFF) is correctly extracted. + function testConsumeSubParseWordInputDataMaxConstantsHeight() external pure { + bytes memory word = bytes("x"); + bytes32[] memory operandValues = new bytes32[](0); + bytes memory data = buildInputData(0xFFFF, 0x00, word, operandValues); + + (uint256 extractedHeight,,) = LibSubParse.consumeSubParseWordInputData(data, "", ""); + assertEq(extractedHeight, 0xFFFF); + } + + /// @notice Empty word: zero-length word string is handled correctly. + function testConsumeSubParseWordInputDataEmptyWord() external pure { + bytes memory word = bytes(""); + bytes32[] memory operandValues = new bytes32[](0); + bytes memory data = buildInputData(5, 0x10, word, operandValues); + + (uint256 constantsHeight, uint256 ioByte, ParseState memory state) = + LibSubParse.consumeSubParseWordInputData(data, "", ""); + + assertEq(constantsHeight, 5); + assertEq(ioByte, 0x10); + assertEq(state.data.length, 0); + } + + /// @notice Longer word: the full word content is preserved in the state. + function testConsumeSubParseWordInputDataLongerWord() external pure { + bytes memory word = bytes("ref-extern-inc"); + bytes32[] memory operandValues = new bytes32[](0); + bytes memory data = buildInputData(100, 0x21, word, operandValues); + + (uint256 constantsHeight, uint256 ioByte, ParseState memory state) = + LibSubParse.consumeSubParseWordInputData(data, "", ""); + + assertEq(constantsHeight, 100); + assertEq(ioByte, 0x21); + assertEq(keccak256(state.data), keccak256(word)); + } + + /// @notice Non-empty operand values round-trip through the input data. + function testConsumeSubParseWordInputDataWithOperands() external pure { + bytes memory word = bytes("op"); + bytes32[] memory operandValues = new bytes32[](2); + operandValues[0] = bytes32(uint256(0xaa)); + operandValues[1] = bytes32(uint256(0xbb)); + bytes memory data = buildInputData(7, 0x42, word, operandValues); + + (uint256 constantsHeight, uint256 ioByte, ParseState memory state) = + LibSubParse.consumeSubParseWordInputData(data, "", ""); + + assertEq(constantsHeight, 7); + assertEq(ioByte, 0x42); + assertEq(keccak256(state.data), keccak256(word)); + assertEq(state.operandValues.length, 2); + assertEq(state.operandValues[0], bytes32(uint256(0xaa))); + assertEq(state.operandValues[1], bytes32(uint256(0xbb))); + } +} diff --git a/test/src/lib/parse/LibSubParse.subParseLiteral.t.sol b/test/src/lib/parse/LibSubParse.subParseLiteral.t.sol new file mode 100644 index 000000000..2ecc07a66 --- /dev/null +++ b/test/src/lib/parse/LibSubParse.subParseLiteral.t.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; +import {LibSubParse} from "src/lib/parse/LibSubParse.sol"; +import {Pointer} from "rain.solmem/lib/LibPointer.sol"; +import {LibBytes} from "rain.solmem/lib/LibBytes.sol"; +import {ISubParserV4} from "rain.interpreter.interface/interface/ISubParserV4.sol"; +import {UnsupportedLiteralType} from "src/error/ErrParse.sol"; + +/// @title LibSubParseSubParseLiteralTest +/// @notice Direct unit tests for `LibSubParse.subParseLiteral`. +contract LibSubParseSubParseLiteralTest is Test { + using LibParseState for ParseState; + using LibSubParse for ParseState; + using LibBytes for bytes; + + /// @notice External wrapper for subParseLiteral so vm.expectRevert works. + function externalSubParseLiteral(bytes memory dispatch, bytes memory body, address[] memory subParserAddresses) + external + view + returns (bytes32) + { + bytes memory data = bytes.concat(dispatch, body); + ParseState memory state = LibParseState.newState(data, "", "", ""); + + // Push sub parsers in reverse so the first address is tried first. + for (uint256 i = subParserAddresses.length; i > 0; i--) { + state.pushSubParser(0, bytes32(uint256(uint160(subParserAddresses[i - 1])))); + } + + uint256 dataPtr = Pointer.unwrap(data.dataPointer()); + uint256 dispatchStart = dataPtr; + uint256 dispatchEnd = dispatchStart + dispatch.length; + uint256 bodyStart = dispatchEnd; + uint256 bodyEnd = bodyStart + body.length; + + return state.subParseLiteral(dispatchStart, dispatchEnd, bodyStart, bodyEnd); + } + + /// @notice Helper to build the expected sub-parse literal data payload. + function buildExpectedData(bytes memory dispatch, bytes memory body) internal pure returns (bytes memory) { + return bytes.concat(bytes2(uint16(dispatch.length)), dispatch, body); + } + + /// @notice Single sub parser successfully resolves a literal. + function testSubParseLiteralSingleSubParserSuccess() external { + address subParser = makeAddr("subParser"); + bytes memory dispatch = bytes("foo"); + bytes memory body = bytes("bar"); + bytes32 expectedValue = bytes32(uint256(42)); + + bytes memory expectedData = buildExpectedData(dispatch, body); + vm.mockCall( + subParser, + abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), + abi.encode(true, expectedValue) + ); + + address[] memory subs = new address[](1); + subs[0] = subParser; + + bytes32 result = this.externalSubParseLiteral(dispatch, body, subs); + assertEq(result, expectedValue); + } + + /// @notice Single sub parser rejects the literal, causing + /// UnsupportedLiteralType revert. + function testSubParseLiteralSingleSubParserRejects() external { + address subParser = makeAddr("subParser"); + bytes memory dispatch = bytes("foo"); + bytes memory body = bytes("bar"); + + bytes memory expectedData = buildExpectedData(dispatch, body); + vm.mockCall( + subParser, + abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), + abi.encode(false, bytes32(0)) + ); + + address[] memory subs = new address[](1); + subs[0] = subParser; + + vm.expectRevert(abi.encodeWithSelector(UnsupportedLiteralType.selector, 0)); + this.externalSubParseLiteral(dispatch, body, subs); + } + + /// @notice First sub parser rejects, second accepts. + function testSubParseLiteralFirstRejectsSecondAccepts() external { + address first = makeAddr("first"); + address second = makeAddr("second"); + bytes memory dispatch = bytes("abc"); + bytes memory body = bytes("xyz"); + bytes32 expectedValue = bytes32(uint256(99)); + + bytes memory expectedData = buildExpectedData(dispatch, body); + vm.mockCall( + first, + abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), + abi.encode(false, bytes32(0)) + ); + vm.mockCall( + second, + abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), + abi.encode(true, expectedValue) + ); + + address[] memory subs = new address[](2); + subs[0] = first; + subs[1] = second; + + bytes32 result = this.externalSubParseLiteral(dispatch, body, subs); + assertEq(result, expectedValue); + } + + /// @notice Both sub parsers reject, causing UnsupportedLiteralType revert. + function testSubParseLiteralAllReject() external { + address first = makeAddr("first"); + address second = makeAddr("second"); + bytes memory dispatch = bytes("abc"); + bytes memory body = bytes("xyz"); + + bytes memory expectedData = buildExpectedData(dispatch, body); + vm.mockCall( + first, + abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), + abi.encode(false, bytes32(0)) + ); + vm.mockCall( + second, + abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), + abi.encode(false, bytes32(0)) + ); + + address[] memory subs = new address[](2); + subs[0] = first; + subs[1] = second; + + vm.expectRevert(abi.encodeWithSelector(UnsupportedLiteralType.selector, 0)); + this.externalSubParseLiteral(dispatch, body, subs); + } + + /// @notice Empty body: only dispatch is present. + function testSubParseLiteralEmptyBody() external { + address subParser = makeAddr("subParser"); + bytes memory dispatch = bytes("pi"); + bytes memory body = bytes(""); + bytes32 expectedValue = bytes32(uint256(314)); + + bytes memory expectedData = buildExpectedData(dispatch, body); + vm.mockCall( + subParser, + abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), + abi.encode(true, expectedValue) + ); + + address[] memory subs = new address[](1); + subs[0] = subParser; + + bytes32 result = this.externalSubParseLiteral(dispatch, body, subs); + assertEq(result, expectedValue); + } + + /// @notice First sub parser accepts, second is never called. + function testSubParseLiteralFirstAcceptsSecondNotCalled() external { + address first = makeAddr("first"); + address second = makeAddr("second"); + bytes memory dispatch = bytes("test"); + bytes memory body = bytes("data"); + bytes32 expectedValue = bytes32(uint256(7)); + + bytes memory expectedData = buildExpectedData(dispatch, body); + vm.mockCall( + first, + abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), + abi.encode(true, expectedValue) + ); + + vm.expectCall(first, abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), 1); + vm.expectCall(second, abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector, expectedData), 0); + + address[] memory subs = new address[](2); + subs[0] = first; + subs[1] = second; + + bytes32 result = this.externalSubParseLiteral(dispatch, body, subs); + assertEq(result, expectedValue); + } +} diff --git a/test/src/lib/parse/LibSubParse.subParseWords.t.sol b/test/src/lib/parse/LibSubParse.subParseWords.t.sol new file mode 100644 index 000000000..715b00b71 --- /dev/null +++ b/test/src/lib/parse/LibSubParse.subParseWords.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; +import {LibSubParse} from "src/lib/parse/LibSubParse.sol"; +import {LibParse} from "src/lib/parse/LibParse.sol"; +import {LibMetaFixture} from "test/lib/parse/LibMetaFixture.sol"; +import {LibBytecode, Pointer} from "rain.interpreter.interface/lib/bytecode/LibBytecode.sol"; +import {ISubParserV4} from "rain.interpreter.interface/interface/ISubParserV4.sol"; +import {IERC165} from "openzeppelin-contracts/contracts/utils/introspection/IERC165.sol"; +import {OPCODE_UNKNOWN, OPCODE_CONSTANT, OPCODE_CONTEXT} from "rain.interpreter.interface/interface/IInterpreterV4.sol"; +import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; + +/// @dev A sub parser that resolves any word by returning a context opcode with +/// no constants. Used to verify that subParseWords iterates multiple sources. +contract ContextReturningSubParser is ISubParserV4, IERC165 { + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(ISubParserV4).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function subParseLiteral2(bytes calldata) external pure override returns (bool, bytes32) { + return (false, 0); + } + + /// @notice Returns a context opcode (0,0) with no constants. + function subParseWord2(bytes calldata) external pure override returns (bool, bytes memory, bytes32[] memory) { + bytes memory bytecode = new bytes(4); + // Safe: opcode constant and IO byte are small known values. + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[0] = bytes1(uint8(OPCODE_CONTEXT)); + //forge-lint: disable-next-line(unsafe-typecast) + bytecode[1] = bytes1(uint8(0x10)); // 0 inputs, 1 output + bytecode[2] = bytes1(0); // row 0 + bytecode[3] = bytes1(0); // column 0 + return (true, bytecode, new bytes32[](0)); + } +} + +/// @title LibSubParseSubParseWordsTest +/// @notice Direct unit tests for `LibSubParse.subParseWords`. +contract LibSubParseSubParseWordsTest is Test { + using LibParseState for ParseState; + using LibSubParse for ParseState; + using LibParse for ParseState; + using Strings for address; + + /// @notice Build a minimal single-source bytecode containing one 4-byte op. + function buildSingleOpBytecode(uint8 opcode, uint8 ioByte, uint16 operand) internal pure returns (bytes memory) { + return abi.encodePacked( + uint8(1), // 1 source + uint16(0), // relative offset = 0 + uint8(1), // ops count = 1 + uint8(0), // stack allocation + uint8(0), // stack high water + uint8(0), // stack inputs + opcode, + ioByte, + operand + ); + } + + /// @notice When bytecode has no sources, subParseWords returns unchanged + /// bytecode and empty constants. + function testSubParseWordsEmptyBytecode() external view { + ParseState memory state = LibParseState.newState("", "", "", ""); + bytes memory bytecode = hex"00"; + (bytes memory result, bytes32[] memory constants) = state.subParseWords(bytecode); + assertEq(result.length, bytecode.length); + assertEq(constants.length, 0); + } + + /// @notice When bytecode has one source with a known (non-unknown) opcode, + /// subParseWords does not modify it. + function testSubParseWordsSingleSourceNoUnknown() external view { + ParseState memory state = LibParseState.newState("", "", "", ""); + bytes memory bytecode = buildSingleOpBytecode( + // Safe: OPCODE_CONSTANT fits in uint8. + //forge-lint: disable-next-line(unsafe-typecast) + uint8(OPCODE_CONSTANT), + 0x10, + 0x0000 + ); + (bytes memory result, bytes32[] memory constants) = state.subParseWords(bytecode); + assertEq(keccak256(result), keccak256(bytecode)); + assertEq(constants.length, 0); + } + + /// @notice When a sub parser resolves a single unknown word via the full + /// parse pipeline, the final bytecode does not contain OPCODE_UNKNOWN and + /// the word is resolved to a valid opcode. + function testSubParseWordsSingleSourceResolvesUnknown() external { + ContextReturningSubParser sub = new ContextReturningSubParser(); + string memory src = string.concat("using-words-from ", address(sub).toHexString(), " _: some-word();"); + ParseState memory state = LibMetaFixture.newState(src); + (bytes memory bytecode, bytes32[] memory constants) = state.parse(); + + uint256 opsCount = LibBytecode.sourceOpsCount(bytecode, 0); + assertEq(opsCount, 1, "Expected 1 op in source 0"); + + Pointer sourcePtr = LibBytecode.sourcePointer(bytecode, 0); + uint256 cursor = Pointer.unwrap(sourcePtr) + 4; + uint8 opcode; + assembly ("memory-safe") { + opcode := byte(0, mload(cursor)) + } + assertEq(uint256(opcode), OPCODE_CONTEXT); + assertEq(constants.length, 0); + } + + /// @notice When parsing an expression with two sources, subParseWords + /// iterates over both and resolves unknown words in each. + function testSubParseWordsTwoSourcesBothResolved() external { + ContextReturningSubParser sub = new ContextReturningSubParser(); + string memory src = + string.concat("using-words-from ", address(sub).toHexString(), " _: some-word(); _: another-word();"); + ParseState memory state = LibMetaFixture.newState(src); + (bytes memory bytecode, bytes32[] memory constants) = state.parse(); + + uint256 sourceCount = LibBytecode.sourceCount(bytecode); + assertEq(sourceCount, 2, "Expected 2 sources"); + + for (uint256 i = 0; i < sourceCount; i++) { + uint256 opsCount = LibBytecode.sourceOpsCount(bytecode, i); + assertEq(opsCount, 1); + Pointer sourcePtr = LibBytecode.sourcePointer(bytecode, i); + uint256 cursor = Pointer.unwrap(sourcePtr) + 4; + uint8 opcode; + assembly ("memory-safe") { + opcode := byte(0, mload(cursor)) + } + assertEq(uint256(opcode), OPCODE_CONTEXT); + } + assertEq(constants.length, 0); + } + + /// @notice External wrapper so reverts can be caught via expectRevert. + function externalParse(string memory src) external view returns (bytes memory, bytes32[] memory) { + ParseState memory state = LibMetaFixture.newState(src); + return state.parse(); + } + + /// @notice When the sub parser rejects a word, parsing reverts with + /// UnknownWord. + function testSubParseWordsUnknownWordReverts() external { + ContextReturningSubParser sub = new ContextReturningSubParser(); + + // Mock subParseWord2 to reject all words. + vm.mockCall( + address(sub), + abi.encodeWithSelector(ISubParserV4.subParseWord2.selector), + abi.encode(false, bytes(""), new bytes32[](0)) + ); + + string memory src = string.concat("using-words-from ", address(sub).toHexString(), " _: unknown-word();"); + + vm.expectRevert(); + this.externalParse(src); + } + + /// @notice Multiple known opcodes in a single source are not modified. + function testSubParseWordsMultipleKnownOpcodes() external view { + ParseState memory state = LibParseState.newState("", "", "", ""); + // Safe: opcode constants fit in uint8. + //forge-lint: disable-next-line(unsafe-typecast) + bytes memory bytecode = abi.encodePacked( + uint8(1), // 1 source + uint16(0), // relative offset = 0 + uint8(2), // ops count = 2 + uint8(0), + uint8(0), + uint8(0), // stack tracker + //forge-lint: disable-next-line(unsafe-typecast) + uint8(OPCODE_CONSTANT), + uint8(0x10), + uint16(0), + //forge-lint: disable-next-line(unsafe-typecast) + uint8(OPCODE_CONTEXT), + uint8(0x10), + uint16(0) + ); + (bytes memory result, bytes32[] memory constants) = state.subParseWords(bytecode); + assertEq(keccak256(result), keccak256(bytecode)); + assertEq(constants.length, 0); + } +} diff --git a/test/src/lib/parse/LibSubParse.unknownWord.t.sol b/test/src/lib/parse/LibSubParse.unknownWord.t.sol index 2a460800f..ec665dc7e 100644 --- a/test/src/lib/parse/LibSubParse.unknownWord.t.sol +++ b/test/src/lib/parse/LibSubParse.unknownWord.t.sol @@ -7,6 +7,7 @@ import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; import {LibParse} from "src/lib/parse/LibParse.sol"; import {UnknownWord} from "src/error/ErrParse.sol"; import {ISubParserV4} from "rain.interpreter.interface/interface/ISubParserV4.sol"; +import {LibAllStandardOps} from "src/lib/op/LibAllStandardOps.sol"; contract LibSubParseUnknownWordTest is Test { using LibParseState for ParseState; @@ -20,6 +21,15 @@ contract LibSubParseUnknownWordTest is Test { (bytecode, constants); } + /// External wrapper with literal parsers for operand value parsing. + function externalParseUnknownWordWithLiteralParsers(bytes memory data, address subParser) external view { + ParseState memory state = + LibParseState.newState(data, "", "", LibAllStandardOps.literalParserFunctionPointers()); + state.pushSubParser(0, bytes32(uint256(uint160(subParser)))); + (bytes memory bytecode, bytes32[] memory constants) = state.parse(); + (bytecode, constants); + } + /// When the only sub-parser rejects a word, parse must revert with /// UnknownWord. function testUnknownWordSingleSubParser(string memory name) external { @@ -35,4 +45,50 @@ contract LibSubParseUnknownWordTest is Test { vm.expectRevert(abi.encodeWithSelector(UnknownWord.selector, "foo")); this.externalParseUnknownWord(bytes("_: foo();"), subParser); } + + /// Unknown word at maximum valid length (31 bytes) exercises the bytecode + /// construction at the word length boundary. + function testUnknownWordMaxLength() external { + address subParser = makeAddr("maxLength"); + + vm.mockCall( + subParser, + abi.encodeWithSelector(ISubParserV4.subParseWord2.selector), + abi.encode(false, bytes(""), new bytes32[](0)) + ); + + // 31-byte word is the maximum before WordSize reverts. + vm.expectRevert(abi.encodeWithSelector(UnknownWord.selector, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + this.externalParseUnknownWord(bytes("_: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa();"), subParser); + } + + /// Unknown word with operand values exercises the bytecode construction + /// with operand data appended after the word. + function testUnknownWordWithOperandValues() external { + address subParser = makeAddr("operandValues"); + + vm.mockCall( + subParser, + abi.encodeWithSelector(ISubParserV4.subParseWord2.selector), + abi.encode(false, bytes(""), new bytes32[](0)) + ); + + vm.expectRevert(abi.encodeWithSelector(UnknownWord.selector, "foo")); + this.externalParseUnknownWordWithLiteralParsers(bytes("_: foo<1 2 3>();"), subParser); + } + + /// Unknown word with single-character name (minimum length) exercises + /// the bytecode construction at the lower bound. + function testUnknownWordMinLength() external { + address subParser = makeAddr("minLength"); + + vm.mockCall( + subParser, + abi.encodeWithSelector(ISubParserV4.subParseWord2.selector), + abi.encode(false, bytes(""), new bytes32[](0)) + ); + + vm.expectRevert(abi.encodeWithSelector(UnknownWord.selector, "z")); + this.externalParseUnknownWord(bytes("_: z();"), subParser); + } } diff --git a/test/src/lib/parse/literal/LibParseLiteral.dispatch.t.sol b/test/src/lib/parse/literal/LibParseLiteral.dispatch.t.sol new file mode 100644 index 000000000..6d1f279f9 --- /dev/null +++ b/test/src/lib/parse/literal/LibParseLiteral.dispatch.t.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {LibBytes, Pointer} from "rain.solmem/lib/LibBytes.sol"; +import {LibParseState, ParseState} from "src/lib/parse/LibParseState.sol"; +import {LibParseLiteral, UnsupportedLiteralType} from "src/lib/parse/literal/LibParseLiteral.sol"; +import {LibAllStandardOps} from "src/lib/op/LibAllStandardOps.sol"; +import {LibDecimalFloat, Float} from "rain.math.float/lib/LibDecimalFloat.sol"; +import {ISubParserV4} from "rain.interpreter.interface/interface/ISubParserV4.sol"; +import {LibIntOrAString, IntOrAString} from "rain.intorastring/lib/LibIntOrAString.sol"; + +/// @title LibParseLiteralDispatchTest +/// @notice Tests for tryParseLiteral dispatch and parseLiteral revert path. +contract LibParseLiteralDispatchTest is Test { + using LibBytes for bytes; + using LibParseState for ParseState; + using LibParseLiteral for ParseState; + + /// External wrapper for parseLiteral so expectRevert works. + function externalParseLiteral(bytes memory data) external view returns (uint256 newCursor, bytes32 value) { + ParseState memory state = + LibParseState.newState(data, "", "", LibAllStandardOps.literalParserFunctionPointers()); + state.literalParsers = LibAllStandardOps.literalParserFunctionPointers(); + uint256 cursor = Pointer.unwrap(data.dataPointer()); + uint256 end = cursor + data.length; + return state.parseLiteral(cursor, end); + } + + /// External wrapper for tryParseLiteral. + function externalTryParseLiteral(bytes memory data) + external + view + returns (bool success, uint256 newCursor, bytes32 value) + { + ParseState memory state = + LibParseState.newState(data, "", "", LibAllStandardOps.literalParserFunctionPointers()); + state.literalParsers = LibAllStandardOps.literalParserFunctionPointers(); + uint256 cursor = Pointer.unwrap(data.dataPointer()); + uint256 end = cursor + data.length; + return state.tryParseLiteral(cursor, end); + } + + /// External wrapper for tryParseLiteral with a sub-parser configured. + function externalTryParseLiteralWithSubParser(bytes memory data, address subParser) + external + view + returns (bool success, uint256 newCursor, bytes32 value) + { + ParseState memory state = + LibParseState.newState(data, "", "", LibAllStandardOps.literalParserFunctionPointers()); + state.literalParsers = LibAllStandardOps.literalParserFunctionPointers(); + state.pushSubParser(0, bytes32(uint256(uint160(subParser)))); + uint256 cursor = Pointer.unwrap(data.dataPointer()); + uint256 end = cursor + data.length; + return state.tryParseLiteral(cursor, end); + } + + /// Decimal literal dispatch (head is '1'-'9'). + function testTryParseLiteralDecimal() external view { + (bool success,,) = this.externalTryParseLiteral(bytes("42 ")); + assertTrue(success, "decimal dispatch"); + } + + /// Hex literal dispatch (head is '0x'). + function testTryParseLiteralHex() external view { + (bool success,,) = this.externalTryParseLiteral(bytes("0xFF ")); + assertTrue(success, "hex dispatch"); + } + + /// '0' followed by non-'x' routes to decimal, not hex. + function testTryParseLiteralZeroDecimal() external view { + (bool success,,) = this.externalTryParseLiteral(bytes("0 ")); + assertTrue(success, "zero decimal dispatch"); + } + + /// String literal dispatch (head is '"'). + function testTryParseLiteralString() external view { + (bool success,,) = this.externalTryParseLiteral(bytes("\"hi\" ")); + assertTrue(success, "string dispatch"); + } + + /// Unrecognized literal type returns false from tryParseLiteral. + function testTryParseLiteralUnrecognized() external view { + // '@' is not a valid literal head. + (bool success,,) = this.externalTryParseLiteral(bytes("@ ")); + assertFalse(success, "unrecognized returns false"); + } + + /// Negative decimal literal dispatch (head is '-'). + function testTryParseLiteralNegativeDecimal() external view { + (bool success,,) = this.externalTryParseLiteral(bytes("-1 ")); + assertTrue(success, "negative decimal dispatch"); + } + + /// Hex literal returns the correct parsed value. + function testTryParseLiteralHexValue() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("0xFF ")); + assertTrue(success, "hex success"); + assertEq(value, bytes32(uint256(0xFF)), "hex value"); + } + + /// Zero literal returns zero value. + function testTryParseLiteralZeroValue() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("0 ")); + assertTrue(success, "zero success"); + assertEq(value, bytes32(0), "zero value"); + } + + /// Uppercase '0X' does NOT route to hex — only lowercase '0x' does. + /// '0X' routes to decimal and parses '0', leaving 'X' for the next token. + function testTryParseLiteralUppercaseXNotHex() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("0X ")); + assertTrue(success, "0X dispatches as decimal"); + assertEq(value, bytes32(0), "0X value is 0"); + } + + /// Decimal literal returns correct float-encoded value. + function testTryParseLiteralDecimalValue() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("42 ")); + assertTrue(success, "decimal success"); + assertEq(value, Float.unwrap(LibDecimalFloat.packLossless(42, 0)), "decimal 42 value"); + } + + /// Sub-parseable literal dispatch (head is '[') reaches the sub-parseable + /// parser. A mocked sub-parser accepts the literal, proving dispatch + /// reached the correct branch end-to-end. + function testTryParseLiteralSubParseableDispatch() external { + address subParser = makeAddr("subParser"); + bytes32 expectedValue = bytes32(uint256(0x42)); + + vm.mockCall( + subParser, abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector), abi.encode(true, expectedValue) + ); + vm.expectCall(subParser, abi.encodeWithSelector(ISubParserV4.subParseLiteral2.selector)); + + (bool success,, bytes32 value) = this.externalTryParseLiteralWithSubParser(bytes("[1]"), subParser); + assertTrue(success, "sub-parseable dispatch"); + assertEq(value, expectedValue, "sub-parseable value"); + } + + /// Multiple unrecognized characters all return false. + function testTryParseLiteralUnrecognizedMultiple() external view { + // Safe: single ASCII characters fit in bytes1. + //forge-lint: disable-next-line(unsafe-typecast) + bytes1[4] memory chars = [bytes1("@"), bytes1("!"), bytes1("#"), bytes1("$")]; + for (uint256 i = 0; i < chars.length; i++) { + //forge-lint: disable-next-line(unsafe-typecast) + (bool success,,) = this.externalTryParseLiteral(abi.encodePacked(chars[i], bytes1(" "))); + assertFalse(success, string(abi.encodePacked("unrecognized: ", chars[i]))); + } + } + + /// String literal returns the correct IntOrAString-encoded value. + function testTryParseLiteralStringValue() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("\"hi\" ")); + assertTrue(success, "string success"); + assertEq(value, bytes32(IntOrAString.unwrap(LibIntOrAString.fromStringV3("hi"))), "string value"); + } + + /// Negative decimal literal returns correct float-encoded value. + function testTryParseLiteralNegativeDecimalValue() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("-1 ")); + assertTrue(success, "negative decimal success"); + assertEq(value, Float.unwrap(LibDecimalFloat.packLossless(-1, 0)), "negative decimal value"); + } + + /// tryParseLiteral advances cursor past the parsed literal. + function testTryParseLiteralCursorAdvancement() external view { + bytes memory data = bytes("42 rest"); + (bool success, uint256 newCursor,) = this.externalTryParseLiteral(data); + assertTrue(success, "cursor advance success"); + uint256 start = Pointer.unwrap(data.dataPointer()); + // "42" is 2 bytes, cursor should advance by 2. + assertEq(newCursor - start, 2, "cursor advanced past literal"); + } + + /// parseLiteral happy path returns same value as tryParseLiteral. + function testParseLiteralHappyPath() external view { + (, bytes32 value) = this.externalParseLiteral(bytes("0xFF ")); + assertEq(value, bytes32(uint256(0xFF)), "parseLiteral hex value"); + } + + /// Hex literal cursor advances past all hex digits. + function testTryParseLiteralHexCursorAdvancement() external view { + bytes memory data = bytes("0xABCD rest"); + (bool success, uint256 newCursor,) = this.externalTryParseLiteral(data); + assertTrue(success, "hex cursor success"); + uint256 start = Pointer.unwrap(data.dataPointer()); + // "0xABCD" is 6 bytes. + assertEq(newCursor - start, 6, "hex cursor advanced"); + } + + /// String literal cursor advances past closing quote. + function testTryParseLiteralStringCursorAdvancement() external view { + bytes memory data = bytes("\"hi\" rest"); + (bool success, uint256 newCursor,) = this.externalTryParseLiteral(data); + assertTrue(success, "string cursor success"); + uint256 start = Pointer.unwrap(data.dataPointer()); + // '"hi"' is 4 bytes. + assertEq(newCursor - start, 4, "string cursor advanced"); + } + + /// Negative multi-digit decimal returns correct float-encoded value. + function testTryParseLiteralNegativeMultiDigit() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("-42 ")); + assertTrue(success, "negative multi-digit success"); + assertEq(value, Float.unwrap(LibDecimalFloat.packLossless(-42, 0)), "negative 42 value"); + } + + /// Full 32-byte hex literal parses correctly. + function testTryParseLiteralMaxHex() external view { + (bool success,, bytes32 value) = + this.externalTryParseLiteral(bytes("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF ")); + assertTrue(success, "max hex success"); + assertEq(value, bytes32(type(uint256).max), "max hex value"); + } + + /// Empty string literal parses correctly. + function testTryParseLiteralEmptyString() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("\"\" ")); + assertTrue(success, "empty string success"); + assertEq(value, bytes32(IntOrAString.unwrap(LibIntOrAString.fromStringV3(""))), "empty string value"); + } + + /// Negative zero parses as decimal zero. + function testTryParseLiteralNegativeZero() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("-0 ")); + assertTrue(success, "negative zero success"); + assertEq(value, Float.unwrap(LibDecimalFloat.packLossless(0, 0)), "negative zero value"); + } + + /// Minimum even hex literal parses correctly. + function testTryParseLiteralMinHex() external view { + (bool success,, bytes32 value) = this.externalTryParseLiteral(bytes("0x00 ")); + assertTrue(success, "min hex success"); + assertEq(value, bytes32(0), "min hex value"); + } + + /// parseLiteral reverts with UnsupportedLiteralType for unrecognized head. + function testParseLiteralUnsupportedType() external { + vm.expectRevert(abi.encodeWithSelector(UnsupportedLiteralType.selector, 0)); + this.externalParseLiteral(bytes("@ ")); + } +} diff --git a/test/src/lib/parse/literal/LibParseLiteralHex.parseHex.t.sol b/test/src/lib/parse/literal/LibParseLiteralHex.parseHex.t.sol index cd9c27103..7af02ec6e 100644 --- a/test/src/lib/parse/literal/LibParseLiteralHex.parseHex.t.sol +++ b/test/src/lib/parse/literal/LibParseLiteralHex.parseHex.t.sol @@ -51,6 +51,8 @@ contract LibParseLiteralHexParseHexTest is Test { if (trailingByte >= 0x41) trailingByte += 6; // skip A-F if (trailingByte >= 0x61) trailingByte += 6; // skip a-f + // Safe: "0x" fits in bytes2; trailingByte is uint8. + //forge-lint: disable-next-line(unsafe-typecast) bytes memory data = abi.encodePacked(bytes2("0x"), bytes1(trailingByte)); vm.expectRevert(abi.encodeWithSelector(ZeroLengthHexLiteral.selector, 2)); @@ -76,6 +78,44 @@ contract LibParseLiteralHexParseHexTest is Test { this.externalParseHex(data); } + /// Mixed-case hex digits parse to the correct value. + function testParseHexMixedCase() external pure { + // 0xAbCd = 0xabcd = 43981 + bytes memory data = bytes("0xAbCd"); + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + (, bytes32 value) = state.parseHex(cursor, Pointer.unwrap(data.endDataPointer())); + assertEq(value, bytes32(uint256(0xAbCd)), "mixed case value"); + } + + /// All uppercase hex digits parse correctly. + function testParseHexUpperCase() external pure { + bytes memory data = bytes("0xABCDEF"); + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + (, bytes32 value) = state.parseHex(cursor, Pointer.unwrap(data.endDataPointer())); + assertEq(value, bytes32(uint256(0xABCDEF)), "upper case value"); + } + + /// All lowercase hex digits parse correctly. + function testParseHexLowerCase() external pure { + bytes memory data = bytes("0xabcdef"); + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + (, bytes32 value) = state.parseHex(cursor, Pointer.unwrap(data.endDataPointer())); + assertEq(value, bytes32(uint256(0xabcdef)), "lower case value"); + } + + /// Alternating case across all hex alpha digits. + function testParseHexAlternatingCase() external pure { + // aB cD eF Ab Cd Ef + bytes memory data = bytes("0xaBcDeFAbCdEf"); + ParseState memory state = LibParseState.newState(data, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + (, bytes32 value) = state.parseHex(cursor, Pointer.unwrap(data.endDataPointer())); + assertEq(value, bytes32(uint256(0xaBcDeFAbCdEf)), "alternating case value"); + } + /// External wrapper that constructs ParseState internally so memory /// pointers remain valid across the external call boundary. function externalParseHex(bytes memory data) external pure returns (uint256, bytes32) { diff --git a/test/src/lib/parse/literal/LibParseLiteralString.boundString.t.sol b/test/src/lib/parse/literal/LibParseLiteralString.boundString.t.sol index cff1ad176..ebbb9473d 100644 --- a/test/src/lib/parse/literal/LibParseLiteralString.boundString.t.sol +++ b/test/src/lib/parse/literal/LibParseLiteralString.boundString.t.sol @@ -99,4 +99,26 @@ contract LibParseLiteralStringBoundTest is Test { this.externalBoundLiteralForceLength(bytes(str), length); (outerStart, innerStart, innerEnd, outerEnd); } + + /// UnclosedStringLiteral when end == innerEnd: the closing quote character + /// is at exactly the end boundary, so there is no room for it. + function testBoundStringUnclosedAtEndBoundary() external { + // "hi" — length 4. Force length to 3 so end points at the closing ". + // innerEnd scans "hi" (2 chars), lands at offset 3 which equals end. + bytes memory data = bytes("\"hi\""); + vm.expectRevert(abi.encodeWithSelector(UnclosedStringLiteral.selector, 3)); + this.externalBoundLiteralForceLength(data, 3); + } + + /// Fuzz variant: any valid string with end set to exclude the closing + /// quote hits the end == innerEnd branch. + function testBoundStringUnclosedAtEndBoundaryFuzz(string memory str) external { + vm.assume(bytes(str).length > 0 && bytes(str).length < 0x20); + LibConformString.conformValidPrintableStringContent(str); + bytes memory data = bytes(string.concat("\"", str, "\"")); + // Force length to data.length - 1, placing end at the closing ". + uint256 length = data.length - 1; + vm.expectRevert(abi.encodeWithSelector(UnclosedStringLiteral.selector, length)); + this.externalBoundLiteralForceLength(data, length); + } } diff --git a/test/src/lib/parse/literal/LibParseLiteralString.parseString.t.sol b/test/src/lib/parse/literal/LibParseLiteralString.parseString.t.sol index bcd4892c3..a3f4a3e72 100644 --- a/test/src/lib/parse/literal/LibParseLiteralString.parseString.t.sol +++ b/test/src/lib/parse/literal/LibParseLiteralString.parseString.t.sol @@ -46,6 +46,27 @@ contract LibParseLiteralStringTest is Test { assertEq(cursorAfter, cursor + data.length + 2); } + /// parseString temporarily overwrites memory before the string content + /// with a length prefix, then restores it. Verify the data bytes are + /// intact after parsing by checking that a second parse of the same + /// data produces the same result. + function testParseStringMemoryRestoration(bytes memory data) external pure { + LibConformString.conformStringToMask(string(data), CMASK_STRING_LITERAL_TAIL, 0x80); + vm.assume(data.length > 0 && data.length < 32); + bytes memory wrapped = bytes(string.concat("\"", string(data), "\"")); + ParseState memory state = LibParseState.newState(wrapped, "", "", ""); + uint256 cursor = Pointer.unwrap(state.data.dataPointer()); + uint256 end = Pointer.unwrap(state.data.endDataPointer()); + + (, bytes32 value1) = state.parseString(cursor, end); + // If memory was not restored, the data length or content would be + // corrupted and the second parse would produce a different result + // or revert. + (, bytes32 value2) = state.parseString(cursor, end); + assertEq(value1, value2, "memory restoration: values differ"); + assertEq(state.data.length, wrapped.length, "memory restoration: data length corrupted"); + } + /// If any character in the string is not in the mask, the parser will error. function testParseStringLiteralCorrupt(bytes memory data, uint256 corruptIndex) external { vm.assume(data.length > 0);