diff --git a/.gitignore b/.gitignore index 010f945d7..70fdc9ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,8 @@ compile_commands.json [Bb]uild*/ .ccache/ .vcpkg-binary-cache/ -cmake-build-debug/ +# CLion-style build dirs: cmake-build-debug/, cmake-build-debug-vcpkg/, cmake-build-release/, ... +cmake-build-*/ examples/multi_index_example/build examples/hello/build @@ -67,3 +68,14 @@ tmp/ # oh-my-claudecode runtime state (operational artifacts, never committed) .omc/ + +# prequel local review state (operational artifacts, never committed) +.prequel/ + +# Core dumps. Restricted to the shapes the kernel actually writes here -- core_pattern is +# core.%e.%p -- and root-anchored, so neither a tracked header such as +# libraries/boost/include/boost/hana/core.hpp nor a future core.cpp/core.hpp is hidden. +/core +/core.[0-9]* +/core.*.[0-9]* +/vgcore.[0-9]* diff --git a/libraries/sysiolib/contracts/sysio/kv_multi_index.hpp b/libraries/sysiolib/contracts/sysio/kv_multi_index.hpp index 196bd9bf0..d65024b3b 100644 --- a/libraries/sysiolib/contracts/sysio/kv_multi_index.hpp +++ b/libraries/sysiolib/contracts/sysio/kv_multi_index.hpp @@ -2,8 +2,9 @@ /** * KV-backed multi_index emulation layer. * - * Drop-in replacement for sysio::multi_index that uses KV intrinsics instead - * of legacy db_*_i64 intrinsics. Same template API, different backend. + * A shim for sysio::multi_index that uses KV intrinsics instead of the legacy db_*_i64 + * ones. The template API is the same in almost every respect; the places it is not are + * listed above the class. * * Key encoding: [scope: 8B BE][primary_key: 8B BE] = 16 bytes. * Table name is encoded in table_id (DJB2 hash of raw template parameter), @@ -22,12 +23,14 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include @@ -140,7 +143,38 @@ namespace _kv_multi_index_detail { } // namespace _kv_multi_index_detail // Uses sysio::indexed_by and sysio::const_mem_fun from the standard CDT headers. -// This class is a drop-in replacement: just change multi_index -> kv_multi_index. +// +// A shim for the EOSIO multi_index over a different store. Nearly all contract code carries +// over. Two things do not, and they are worth keeping apart. +// +// SOURCE BREAKS AGAINST UPSTREAM -- code that compiles there and not here: +// +// - the postfix iterator operators are deleted, because copying a KV iterator duplicates a +// host-side handle. Note rbegin()/rend() hand back a std::reverse_iterator, whose postfix +// operators are the adaptor's and are NOT deleted, so reverse loops compile silently and +// the sweep does not find them; +// - the primary bounds are uint64_t/name overloads where upstream has a member template, so +// an explicit call -- `t.template lower_bound(k)`, likewise upper_bound -- is +// rejected with "does not refer to a template". A wrapper convertible to BOTH uint64_t and +// name is also ambiguous here (see the note at the bounds). +// +// RESTRICTIONS SHARED WITH UPSTREAM, which are not breaks even though they bite: +// +// - taking the bare address of a primary bound, `&table::lower_bound`, does not compile -- +// here because the name is an overload set, upstream because a member template's parameter +// cannot be deduced. A named static_cast resolves one on Wire; +// - a secondary key must be trivially copyable. Upstream's supported secondary types are all +// trivially copyable too; what differs is only where it is diagnosed. The static_assert +// lives in secondary_index_view, so it fires at get_index<...>(), not at declaration. +// +// The mutators reject a duplicate primary key and a handle whose code is not the receiving +// account, matching upstream. Each guard is documented where it stands. +// +// sysio::multi_index is a direct alias of this template. sysio::singleton is not: it aliases +// kv_singleton, which holds a kv_multi_index as a PRIVATE member. Its surface is single-row +// accessors and mutators -- not the table's iterators, bounds or secondary-index API -- so it +// is bound by the mutators' guards, and a singleton handle on another account is read-only, +// but none of the divergences above are reachable through it. template class kv_multi_index { @@ -155,6 +189,20 @@ class kv_multi_index { static uint64_t to_pk_uint64(uint64_t pk) { return pk; } static uint64_t to_pk_uint64(name pk) { return pk.value; } + /// The receiving account, avoiding a host call where possible. + /// + /// The generated dispatcher stores the receiver in sysio_contract_name at the top of + /// apply() -- and apply() is re-entered per receiver, so it is correct under notification + /// too -- making this a plain global read. SYSIO_DISPATCH emits its own strong apply() and + /// the native dispatch sets nothing, leaving the global 0, which is not a valid account + /// name and so is a safe "unset" sentinel; there we pay the intrinsic, as upstream always + /// does. Deliberately not cached on the object: a contract may hold a `static` table, and + /// the receiver differs between the initial action and a notification handler. + static name receiving_account() { + const name ctx = current_context_contract(); + return ctx.value ? ctx : current_receiver(); + } + name _code; uint64_t _scope; mutable uint64_t _next_primary_key = 0; @@ -312,7 +360,7 @@ class kv_multi_index { using extractor_t = typename Index::secondary_extractor_type; extractor_t ext; auto sec_key = idx.encode_scoped_secondary(ext(obj)); - auto pri_key = idx.pk_to_bytes(obj.primary_key()); + auto pri_key = idx.pk_to_bytes(kv_multi_index::to_pk_uint64(obj.primary_key())); ::kv_idx_store(payer, _sec_tid, pri_key.data, _kv_multi_index_detail::u64_size, sec_key.data(), sec_key.size()); @@ -325,7 +373,7 @@ class kv_multi_index { using extractor_t = typename Index::secondary_extractor_type; extractor_t ext; auto sec_key = idx.encode_scoped_secondary(ext(obj)); - auto pri_key = idx.pk_to_bytes(obj.primary_key()); + auto pri_key = idx.pk_to_bytes(kv_multi_index::to_pk_uint64(obj.primary_key())); ::kv_idx_remove(_sec_tid, pri_key.data, _kv_multi_index_detail::u64_size, sec_key.data(), sec_key.size()); @@ -339,7 +387,7 @@ class kv_multi_index { extractor_t ext; auto old_sec = idx.encode_scoped_secondary(ext(old_obj)); auto new_sec = idx.encode_scoped_secondary(ext(new_obj)); - auto pri_key = idx.pk_to_bytes(old_obj.primary_key()); + auto pri_key = idx.pk_to_bytes(kv_multi_index::to_pk_uint64(old_obj.primary_key())); if (old_sec != new_sec) { ::kv_idx_update(payer, _sec_tid, pri_key.data, _kv_multi_index_detail::u64_size, @@ -592,6 +640,24 @@ class kv_multi_index { return *obj; } + /// Two concrete overloads, the same shape find/require_find/get use above: a one-line + /// `name` form delegating to the `uint64_t` one. + /// + /// Concrete overloads rather than a template or a converting-proxy parameter, because both + /// of those change what the argument means. A template cannot deduce `lower_bound({42})`; + /// a proxy accepts `lower_bound({w})` for a `w` converting to a narrower type, which a real + /// `uint64_t` parameter rejects as narrowing. The parameter here is a `uint64_t`, so every + /// conversion is the one a `uint64_t` parameter performs. + /// + /// Two consequences of the overload pair, both shared with the three siblings above: + /// + /// - `&table::lower_bound` is an overload set, so the bare address cannot be taken. A + /// named cast resolves either one: + /// `static_cast(&table::lower_bound)`. + /// - a wrapper convertible to BOTH `uint64_t` and `name` is ambiguous. + /// + /// Both are pinned by test. + const_iterator lower_bound(name primary) const { return lower_bound(primary.value); } const_iterator lower_bound(uint64_t primary) const { auto key = make_pk(primary); auto prefix = make_prefix(); @@ -600,6 +666,7 @@ class kv_multi_index { return const_iterator(this, handle, status == 0); } + const_iterator upper_bound(name primary) const { return upper_bound(primary.value); } const_iterator upper_bound(uint64_t primary) const { if (primary == std::numeric_limits::max()) return end(); return lower_bound(primary + 1); @@ -620,6 +687,14 @@ class kv_multi_index { template const_iterator emplace(name payer, Lambda&& constructor) { + // Reads honour _code (kv_get/kv_contains take a code argument) but writes do not: + // kv_set and kv_idx_store have no code parameter and always land on the receiver. A + // foreign-code handle would therefore probe one account and write another -- upstream + // rejects that, and a ported contract relying on the abort would otherwise get a silent + // write to its own row. Checked before the constructor runs, so a lambda with side + // effects is not executed on the rejected path. + check(_code == receiving_account(), "cannot create objects in table of another contract"); + T obj; constructor(obj); @@ -627,6 +702,15 @@ class kv_multi_index { auto key = make_pk(pk); auto value = serialize_row(obj); + // Reject a duplicate primary key. Nothing below this point will: kv_set is an upsert, + // so the row would be silently overwritten, and store_secondaries is an unconditional + // kv_idx_store, so the old (sec_key -> pri_key) mapping would survive and point at a + // row whose secondary value has changed. On Antelope db_store_i64 rejected duplicates + // at the chain layer; the KV intrinsics do not, so the wrapper must. + // kv::table::emplace checks the same way. + check(!::kv_contains(_table_id, _code.value, key.data, key_size), + "object with the same primary key already exists"); + ::kv_set(_table_id, payer.value, key.data, key_size, value.data(), value.size()); store_secondaries(payer.value, obj); @@ -652,6 +736,8 @@ class kv_multi_index { template void modify(const T& obj, name payer, Lambda&& updater) { + check(_code == receiving_account(), "cannot modify objects in table of another contract"); + T old_obj = obj; // Cast away const for modification (same pattern as legacy multi_index) auto& mutable_obj = const_cast(obj); @@ -679,6 +765,8 @@ class kv_multi_index { } void erase(const T& obj) { + check(_code == receiving_account(), "cannot erase objects in table of another contract"); + uint64_t pk = to_pk_uint64(obj.primary_key()); auto key = make_pk(pk); diff --git a/libraries/sysiolib/contracts/sysio/kv_table.hpp b/libraries/sysiolib/contracts/sysio/kv_table.hpp index 74a1bcd27..c3f72df65 100644 --- a/libraries/sysiolib/contracts/sysio/kv_table.hpp +++ b/libraries/sysiolib/contracts/sysio/kv_table.hpp @@ -435,7 +435,18 @@ class table_impl { sec_ops::update_all(*this, payer, pri.data(), pri.size(), old_val, new_val); } - // Internal insert (no duplicate check — caller must verify) +private: + // Internal insert (no duplicate check — caller must verify). + // + // Private deliberately. Inserting over an existing key here silently overwrites the row + // and, because store_secondaries is an unconditional kv_idx_store, either strands the old + // (sec_key -> pri_key) mapping (when the secondary value changed) or trips the host's + // ordered_unique constraint on (code, table_id, sec_key, pri_key). emplace() pays one + // kv_contains so no PUBLIC path reaches an unguarded insert. + // + // Only do_insert is sealed. store_secondaries, remove_secondaries, update_secondaries and + // do_erase below are public, and calling store_secondaries directly strands a mapping the + // same way -- treat them as internal. void do_insert(uint64_t payer, const be_key_stream& k, const K& key, const V& value) { if constexpr (is_fixed_serializable_v) { char vbuf[sizeof(V)]; @@ -448,6 +459,7 @@ class table_impl { store_secondaries(payer, key, value); } +public: // Internal erase used by both primary and secondary erase paths void do_erase(const K& key, const V& value) { remove_secondaries(key, value); @@ -695,6 +707,17 @@ class table_impl { /// Insert a new row. Asserts if the key already exists. Use upsert()/set() /// for insert-or-update semantics. + /// + /// WRITES IGNORE code(), as they do in kv::global. kv_get and kv_contains take a code + /// argument, so reads honour whatever account this handle was constructed with; kv_set, + /// kv_erase and kv_idx_store have no such parameter and always land on the current + /// receiver. A handle opened on a FOREIGN account is therefore read-only in practice -- + /// mutating through one probes their table and writes your own, and because table_id is + /// derived from the table name alone, that write lands on your row of the same name. + /// Nothing detects it at compile time. Construct foreign-code handles for reading only. + /// + /// (sysio::multi_index does guard this, because upstream does and ported contracts rely + /// on the abort; these wrappers have no such compatibility obligation.) void emplace(name payer, const K& key, const V& value, const char* exists_msg = "key already exists") { auto k = make_key(key); sysio::check(!::kv_contains(_table_id, code(), k.data(), k.size()), exists_msg); diff --git a/libraries/sysiolib/core/sysio/context.hpp b/libraries/sysiolib/core/sysio/context.hpp index c50845763..27b5ea131 100644 --- a/libraries/sysiolib/core/sysio/context.hpp +++ b/libraries/sysiolib/core/sysio/context.hpp @@ -4,8 +4,11 @@ namespace sysio { namespace internal_use_do_not_use { - extern "C" uint64_t sysio_contract_name; + /// volatile MUST match the definition in sysiolib.cpp -- differing cv-qualification on + /// the same entity is ill-formed (no diagnostic required). It links today only because + /// extern "C" names carry no type and no translation unit sees both spellings. + extern "C" volatile uint64_t sysio_contract_name; } - inline name current_context_contract() { return name{internal_use_do_not_use::sysio_contract_name}; } + inline name current_context_contract() { return name{uint64_t{internal_use_do_not_use::sysio_contract_name}}; } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0ed05d0bd..48f8401fe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ add_unit_test( time_tests ) add_unit_test( varint_tests ) add_unit_test( kv_table_tests ) add_unit_test( kv_cached_tests ) +add_unit_test( kv_multi_index_tests ) add_test( NAME toolchain_tests COMMAND ${CMAKE_BINARY_DIR}/tools/toolchain-tester/toolchain-tester ${CMAKE_SOURCE_DIR}/tests/toolchain --cdt ${CMAKE_BINARY_DIR}/bin --verbose ) set_property(TEST toolchain_tests PROPERTY LABELS toolchain_tests) @@ -36,6 +37,10 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/abi_version_tests.sh ${CMAKE_BIN add_test(NAME abi_version_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/abi_version_tests.sh "${CMAKE_BINARY_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST abi_version_tests PROPERTY LABELS unit_tests) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/dispatch_receiver_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/dispatch_receiver_tests.sh COPYONLY) +add_test(NAME dispatch_receiver_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/dispatch_receiver_tests.sh "${CMAKE_BINARY_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +set_property(TEST dispatch_receiver_tests PROPERTY LABELS unit_tests) + configure_file(${CMAKE_CURRENT_SOURCE_DIR}/unit/multidir_contract_tests.sh ${CMAKE_BINARY_DIR}/tests/unit/multidir_contract_tests.sh COPYONLY) add_test(NAME multidir_contract_tests COMMAND ${CMAKE_BINARY_DIR}/tests/unit/multidir_contract_tests.sh "${CMAKE_BINARY_DIR}" WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_property(TEST multidir_contract_tests PROPERTY LABELS unit_tests) diff --git a/tests/integration/multi_index_tests.cpp b/tests/integration/multi_index_tests.cpp index d31d3f81f..ec0e0f246 100644 --- a/tests/integration/multi_index_tests.cpp +++ b/tests/integration/multi_index_tests.cpp @@ -35,6 +35,15 @@ BOOST_FIXTURE_TEST_CASE(main_multi_index_tests, TESTER) { try { }; push_action( "testapi"_n, "s1g"_n, "testapi"_n, {} ); // idx64_general + push_action( "testapi"_n, "s1namepk"_n, "testapi"_n, {} ); // name_pk_secondaries + + // A foreign-code handle cannot mutate: reads honour the handle's code but writes land on + // the receiver, so without the guard this silently wrote the receiver's own row. + check_failure( "s1foreign"_n, "cannot create objects in table of another contract" ); + + // Duplicate primary key aborts instead of upserting. Without the guard this action + // succeeded and left the previous secondary mapping stranded. + check_failure( "s1dupidx"_n, "object with the same primary key already exists" ); push_action( "testapi"_n, "s1store"_n, "testapi"_n, {} ); // idx64_store_only push_action( "testapi"_n, "s1check"_n, "testapi"_n, {} ); // idx64_check_without_storing push_action( "testapi"_n, "s2g"_n, "testapi"_n, {} ); // idx128_general diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index d13093af7..80b088adc 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -35,6 +35,7 @@ add_cdt_unit_test(time_tests) add_cdt_unit_test(varint_tests) add_cdt_unit_test(kv_table_tests) add_cdt_unit_test(kv_cached_tests) +add_cdt_unit_test(kv_multi_index_tests) target_compile_options( rope_tests PUBLIC -g ) add_subdirectory(test_contracts) diff --git a/tests/unit/dispatch_receiver_tests.sh b/tests/unit/dispatch_receiver_tests.sh new file mode 100755 index 000000000..5c8076524 --- /dev/null +++ b/tests/unit/dispatch_receiver_tests.sh @@ -0,0 +1,709 @@ +#!/bin/bash +# The generated apply() must record the RECEIVER in sysio_contract_name. +# +# multi_index's receiving_account() reads that global and falls back to the current_receiver +# intrinsic only when it is 0, so the guards on emplace/modify/erase are only correct if the +# dispatcher stores `r` (the receiver) rather than `c` (the code), exactly once, before any +# dispatch. Every in-tree action is self-sent, so r == c and the whole unit + integration suite +# stays green if that is broken -- the divergence appears only under notification, on chain. +# This pins it at the source, which no other test inspects, in two independent ways. +# +# check_dispatch reads the preprocessed dispatch TEXT: it is what can see that the argument is +# `r` and not `c`, and that the call is the first statement. check_dispatch_symbols reads the +# RELOCATIONS of the emitted object: it is what can see a second call however it was spelled, +# including one reaching the import through an asm label that never spells the identifier +# twice. Neither subsumes the other, so both run. Where the object cannot answer either -- an +# indirect call names a type and not a target -- it is refused rather than guessed at. +# +# Each is exercised three ways: against the real generated dispatch, against a table of crafted +# counterexamples that must each be rejected, and against positive controls that must NOT be. +# Earlier revisions were defeated eight times in review -- by handler names the checker did not +# match, by a branch on the signature line, by two calls on one line, by a comment between the +# identifier and its paren, by a raw string closing at column 1, by a marker whose filename +# carried an escaped quote, by an asm label, and by that same alias called through a function +# pointer -- because each fix pattern-matched the last evasion. Checking the checker is what +# stops that: a new evasion is one row below, not a round trip. +# +# The marker filter is pinned from BOTH sides. Too narrow and it leaves a marker in the +# normalised source, which is read as apply()'s first statement and rejects a correct dispatch; +# too broad and it deletes a line of real code, taking a second setter call with it. Each of +# the four parts of that pattern -- the `^`, the filename grammar, the trailing flags and the +# `$` -- has a row that fails when it alone is weakened, positive rows for the first sense and +# counterexamples for the second. +# +# BOTH checkers report three outcomes, not two, and their callers distinguish all three: +# accepted, rejected, and INFRA_ERROR -- the check could not be performed. Collapsing the third +# into either verdict is how a broken toolchain reads as a green run, and it is the one that +# reads as a PASS: a reject row is satisfied by any non-zero status unless the caller separates +# them. Each analyser has a stand-in that prints, then fails, to pin that. +# +# Usage: dispatch_receiver_tests.sh +set -euo pipefail + +BUILD_DIR="$1" +CDT_CPP="${BUILD_DIR}/bin/cdt-cpp" +LLVM_OBJDUMP="${BUILD_DIR}/bin/llvm-objdump" +PASS=0 +FAIL=0 +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# check_dispatch's exit statuses. 0 is acceptance; these two are not interchangeable. +readonly REJECTED=1 # the dispatch was read, and it breaks the contract +readonly INFRA_ERROR=2 # the dispatch could not be read at all -- no verdict was reached + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# The COMPLETE preprocessor line-marker grammar, anchored at BOTH ends: +# +# # "" [...] +# +# with the filename modelled as clang emits it -- any character except an unescaped quote or +# backslash, or a backslash followed by anything. Every part of that is load-bearing, and each +# was added after a shape that a narrower filter got wrong: +# +# * dropping every '#'-prefixed line deleted `#)d"; ` -- a raw string closing at +# column 1 -- and the executable code on that line with it; +# * dropping `#` followed by a digit deleted `#1)d"; ` the same way; +# * `[^"]*` for the filename does not match `# 7 "a\"b.cpp"`, which is what cdt-cpp -E emits +# for `#line 7 "a\"b.cpp"`. The marker then survives into the normalised source and is +# read as the start of apply()'s first statement, rejecting a CORRECT dispatch; +# * without the trailing `$`, `# 1 "fake")d"; ` matches on its marker-shaped prefix +# and the line -- call included -- is deleted. +# +# Matching the whole grammar leaves any line that is not literally a marker intact. +readonly LINE_MARKER_RE='^# [0-9]+ "([^"\\]|\\.)*"([[:space:]]+[0-9]+)*$' + +# Preprocess $1 into $2, with the driver's diagnostics captured in $3, and strip line markers. +# +# Normalise through the CDT DRIVER, so the tokens counted are the ones that will ship. Earlier +# revisions used the bundled clang++ with the host target and deleted #include lines first. +# Both choices changed the translation unit: the host target evaluates `#ifdef __wasm__` the +# wrong way, so a second setter call guarded on it was invisible while cdt-cpp compiled it +# happily; and dropping includes erases any macro that expands to one. cdt-cpp applies the +# wasm32 target, the CDT include graph and the same predefined macros as the real compile. +# +# -E emits line markers and the driver rejects -P, so they are stripped afterwards. `pipefail` +# is set, so the pipeline reports the driver's status and the caller can tell a preprocessing +# failure from a verdict. +normalise_source() { + "$CDT_CPP" -E "$1" 2>"$3" | sed -E "/${LINE_MARKER_RE}/d" > "$2" +} + +# Decide whether one dispatch file satisfies the contract. Echoes OK, or a reason. +# Returns 0 (accepted), $REJECTED, or $INFRA_ERROR. +check_dispatch() { + local file="$1" clean pp_log pp_status=0 + clean="$(mktemp "${WORK}/clean.XXXXXX")" + pp_log="$(mktemp "${WORK}/pplog.XXXXXX")" + + # An explicit status check, because every call site runs this function on the left of a + # `||` -- which disables errexit for its whole body. Without this, a driver that failed + # after printing something plausible was analysed anyway: acceptable-looking output read + # as OK, and truncated output read as a rejection, which in the counterexample loop below + # is indistinguishable from a PASS. + normalise_source "$file" "$clean" "$pp_log" || pp_status=$? + if [ "$pp_status" -ne 0 ]; then + echo "preprocessing ${file} exited ${pp_status}: $(tr '\n' ' ' < "$pp_log")" + return "$INFRA_ERROR" + fi + + # Every occurrence of the identifier, however spelled. Two are expected: the declaration + # in the extern "C" block and the single call inside apply(). Counting matching LINES, or + # only `identifier(`, both let a second call hide. + local occurrences + occurrences="$(grep -owE 'sysio_set_contract_name' "$clean" | wc -l)" + if [ "$occurrences" -ne 2 ]; then + echo "expected 2 occurrences of sysio_set_contract_name (1 declaration + 1 call), found ${occurrences}" + return "$REJECTED" + fi + + local apply_line + apply_line="$(grep -nE '^[[:space:]]*(__attribute__.*)?void apply\(' "$clean" | head -1 | cut -d: -f1 || true)" + if [ -z "$apply_line" ]; then + echo "no apply() definition found" + return "$REJECTED" + fi + + # The body from immediately after the opening brace, INCLUDING any suffix on the signature + # line, joined into one line. Reading from the next line down misses + # `void apply(...) { if (c == r) {`. + local body first_stmt + body="$(awk -v a="$apply_line" ' + NR < a { next } + NR == a { sub(/^[^{]*\{/, "") } + { print } + ' "$clean" | tr '\n' ' ' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //')" + first_stmt="${body%%;*};" + + # Normalise spacing so `set (r)` and `set(r)` compare alike. + local normalised + normalised="$(printf '%s' "$first_stmt" | tr -d ' ')" + if [ "$normalised" != "sysio_set_contract_name(r);" ]; then + echo "first statement of apply() is: ${first_stmt}" + return "$REJECTED" + fi + echo OK +} + +# The same requirement at the SYMBOL level, over the object the driver actually emits. +# +# The text checker counts SPELLINGS, and a second call can reach the same wasm import without +# adding one: +# +# extern void again(uint64_t) __asm__("sysio_set_" "contract_name"); +# again(c); +# +# The adjacent string literals are still two tokens after preprocessing -- concatenation is +# translation phase 6, which -E does not reach -- so the identifier is spelled twice in the +# file and the text count stays at 2. The object calls the import twice, and the second call +# overwrites the receiver with the code. Relocations do not care how the symbol was spelled. +# +# Ordering comes with it: the first call relocation inside apply() must be this one, which +# states "before any dispatch" over the emitted code rather than over the source text. +# +# Scope is apply() itself, matching the text checker. A setter call made from some OTHER +# function that apply() calls is out of range of both -- the dispatch TU defines only apply(), +# so there is no such function to write today, but it is a real limit rather than a covered +# case. +# +# INDIRECT calls are refused outright rather than analysed. Relocations name the target of a +# DIRECT call; a `call_indirect` names only a type, so its target is exactly what this cannot +# see -- and the address can reach the table without a direct call ever appearing: +# +# setter_fn volatile fp = again; // R_WASM_TABLE_INDEX_SLEB, not FUNCTION_INDEX_LEB +# fp(c); // call_indirect +# +# leaves one direct setter call, first, and a second call to the same import that a scan of +# call relocations cannot count. The generated dispatch is a chain of direct calls and has no +# legitimate indirect one, so the honest answer is to reject rather than to guess. +# +# Echoes OK, or a reason. Returns 0, $REJECTED, or $INFRA_ERROR. +check_dispatch_symbols() { # $1=object file + local records log status=0 calls count first + + # The analyser's own status, for the same reason the preprocessor's is checked: this runs + # on the left of a `||`, which disables errexit for the whole body, so a dump that failed + # after printing something would otherwise be read as a verdict -- a complete-looking dump + # exiting non-zero as acceptance, a truncated one as a rejection, which in a reject row + # reads as a PASS. + log="$(mktemp "${WORK}/objdump.XXXXXX")" + # + # Lines are classified by SHAPE first. llvm-objdump indents a relocation record with tabs + # and an instruction with spaces: + # + # " 18: 10 80 ... \tcall\t0" <- instruction + # "\t\t\t00000019: R_WASM_FUNCTION_INDEX_LEB\tsym+0" <- relocation + # + # and the opcode is compared as the mnemonic FIELD, never as text anywhere on the line. + # Searching the whole line for `call_indirect` reads a relocation to a legal action named + # `call_indirect` -- `__sysio_action_call_indirect_dispatchrcv`, which a contract may + # declare -- as an indirect call, and rejects a correct dispatch. The generated contract in + # section 1 declares exactly that action, so the positive control covers it. + records="$("$LLVM_OBJDUMP" -dr "$1" 2>"$log" | awk ' + /^[0-9a-f]+ <.*>:$/ { in_apply = ($0 ~ /:$/); next } + !in_apply { next } + /^\t/ { + if ($0 ~ /R_WASM_FUNCTION_INDEX_LEB/) { + sym = $NF; sub(/\+[-0-9]+$/, "", sym); print "CALL " sym + } + next + } + { + if (split($0, field, "\t") >= 2) { + mnemonic = field[2] + gsub(/^[ \t]+|[ \t]+$/, "", mnemonic) + if (mnemonic == "call_indirect") print "INDIRECT" + } + }')" || status=$? + if [ "$status" -ne 0 ]; then + echo "llvm-objdump on $(basename "$1") exited ${status}: $(tr '\n' ' ' < "$log")" + return "$INFRA_ERROR" + fi + + if printf '%s\n' "$records" | grep -qx INDIRECT; then + echo "apply() makes an indirect call, whose target this check cannot see" + return "$REJECTED" + fi + + calls="$(printf '%s\n' "$records" | sed -n 's/^CALL //p')" + if [ -z "$calls" ]; then + echo "no call relocations inside apply() in $(basename "$1")" + return "$REJECTED" + fi + count="$(printf '%s\n' "$calls" | grep -cx 'sysio_set_contract_name' || true)" + if [ "$count" -ne 1 ]; then + echo "apply() calls sysio_set_contract_name ${count} time(s), not once" + return "$REJECTED" + fi + first="$(printf '%s\n' "$calls" | head -1)" + if [ "$first" != sysio_set_contract_name ]; then + echo "the first call in apply() is ${first}, not sysio_set_contract_name" + return "$REJECTED" + fi + echo OK +} + +# Run check_dispatch on $1, setting VERDICT to its reason and VERDICT_STATUS to its status. +# +# The `||` is what keeps a non-zero status from aborting the script under errexit -- turning a +# reported failure into a truncated run -- while still recording which status it was. Reading +# only the text cannot tell a rejection from an infrastructure error. +VERDICT="" +VERDICT_STATUS=0 +run_check() { + VERDICT_STATUS=0 + VERDICT="$(check_dispatch "$1")" || VERDICT_STATUS=$? +} + +echo "=== Dispatch Receiver Tests ===" + +# --- 1. the real generated dispatch -------------------------------------------------------- +cat > "${WORK}/c.cpp" <<'EOF' +#include +class [[sysio::contract("dispatchrcv")]] dispatchrcv : public sysio::contract { +public: + using contract::contract; + [[sysio::action]] void go() {} + // A legal action whose generated wrapper is __sysio_action_call_indirect_dispatchrcv. The + // symbol check must read the OPCODE field, not the line, or this relocation reads as an + // indirect call and a correct dispatch is rejected. + [[sysio::action("callindirect")]] void call_indirect() {} + [[sysio::on_notify("sysio.token::transfer")]] void onxfer(sysio::name from, sysio::name to) {} +}; +EOF + +if ! ( cd "$WORK" && "$CDT_CPP" -abigen -abigen_output=c.abi -contract=dispatchrcv \ + -o c.wasm c.cpp ) > "${WORK}/build.log" 2>&1; then + fail "contract builds" + sed 's/^/ /' "${WORK}/build.log" + echo "Results: ${PASS} passed, ${FAIL} failed" + exit 1 +fi +pass "contract builds" + +DISPATCH="$(find "$WORK" -name '*.dispatch.cpp' | head -1)" +if [ -z "$DISPATCH" ]; then + fail "a dispatch.cpp was generated" + echo "Results: ${PASS} passed, ${FAIL} failed" + exit 1 +fi +pass "a dispatch.cpp was generated" + +run_check "$DISPATCH" +if [ "$VERDICT_STATUS" -eq 0 ]; then + pass "the generated apply() records the receiver once, before any dispatch" +else + fail "the generated apply() records the receiver once, before any dispatch" + echo " ${VERDICT}" + sed 's/^/ /' "$DISPATCH" +fi + +# --- 2. the checker itself ----------------------------------------------------------------- +# +# Each fixture compiles. The ones in the negative table each break the contract and every one +# defeated some earlier revision of this test, so they are kept as regressions on the CHECKER; +# the ones in the positive table are correct dispatches that merely look unusual, and pin the +# other direction -- a filter tightened until it rejects real output. +# $2 is appended directly after the opening brace, so a fixture can put text on the SIGNATURE +# line by starting without a newline. This used to emit one unconditionally, which meant no +# counterexample ever exercised that escape even though the header claimed one did. +mkfixture() { # $1=name $2=apply-body (leading newline optional) + cat > "${WORK}/fixture_$1.cpp" <: keeps the preprocessed fixture small + // enough to read when a failure dumps it +extern "C" { + void sysio_set_contract_name(uint64_t n); + void __sysio_action_go_x(uint64_t r, uint64_t c); + void __sysio_notify_on_x(uint64_t r, uint64_t c); + void apply(uint64_t r, uint64_t c, uint64_t a) {$2 + } +} +EOF +} + +mkfixture code_not_receiver ' + sysio_set_contract_name(c); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +mkfixture inside_branch ' + if (c == r) { sysio_set_contract_name(r); __sysio_action_go_x(r, c); } + else { __sysio_notify_on_x(r, c); }' +mkfixture double_call ' + sysio_set_contract_name(r); sysio_set_contract_name(c); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +mkfixture comment_split ' + sysio_set_contract_name(r); sysio_set_contract_name/**/(c); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# A branch opened on the SIGNATURE line, with the setter first on the line below. This is the +# shape that discriminates: read the body from the brace and the first statement is +# `if (c == r) {`, so it is rejected; read it from the next line down -- as an earlier revision +# did -- and the setter looks like the first statement and it is accepted. A fixture whose +# signature line also closes its branch is rejected either way and pins nothing. +mkfixture signature_line ' if (c == r) { + sysio_set_contract_name(r); + __sysio_action_go_x(r, c); + } else { __sysio_notify_on_x(r, c); }' +# Split across a phase-2 line splice, which compiles as one identifier. +mkfixture spliced_call ' + sysio_set_contract_name(r); sysio_set_contract_\ +name(c); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# A backslash followed by horizontal whitespace before the newline. Clang splices it (with a +# warning), so this is a second call; a normaliser matching only an adjacent backslash-newline +# does not see it. +mkfixture spliced_ws ' + sysio_set_contract_name(r); sysio_set_contract_\ +name(c); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# A raw string whose contents look like a comment. A hand-rolled lexer treats the opening quote +# as an ordinary string and the // inside it as a comment, erasing the real call after it. +mkfixture raw_string_comment ' + sysio_set_contract_name(r); + const char* s = R"d(" // )d"; sysio_set_contract_name(c); (void)s; + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# Guarded on the target. The host branch is well-formed, so a checker preprocessing for the +# host counts two occurrences and accepts -- while cdt-cpp compiles the wasm branch, where the +# receiver is immediately overwritten with the code. +mkfixture target_conditional ' +#ifdef __wasm__ + sysio_set_contract_name(r); sysio_set_contract_name(c); +#else + sysio_set_contract_name(r); +#endif + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# The second call arrives from a macro defined in an INCLUDED header -- the shape that a +# checker deleting #include lines cannot see, however well it expands what remains. +cat > "${WORK}/record_again.hpp" <<'EOF' +#pragma once +#define RECORD_AGAIN sysio_set_contract_name(c) +EOF +mkfixture macro_expanded ' +#include "record_again.hpp" + sysio_set_contract_name(r); RECORD_AGAIN; + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# A multiline raw string closing at column 1 after a '#', with the second call on that same +# line. cdt-cpp compiles it and -E emits both calls; a filter that drops every '#'-prefixed +# line deletes the closing delimiter and the call with it. +mkfixture raw_string_hash ' + sysio_set_contract_name(r); + const char* s = R"d( +#)d"; sysio_set_contract_name(c); + (void)s; + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# The same raw-string escape, with a digit after the '#'. A filter matching `#` plus a numeric +# prefix deletes this closing line and the call on it. +mkfixture raw_string_hash_num ' + sysio_set_contract_name(r); + const char* s = R"d( +#1)d"; sysio_set_contract_name(c); + (void)s; + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# The same escape again, but closing on a line whose PREFIX is a complete, well-formed line +# marker. This is what pins the trailing `$`: a filter anchored only at the start matches the +# `# 1 "fake"` prefix, deletes the line, and takes the second call with it -- so the run stays +# green with the anchor removed unless this row is here. The two rows above do not cover it; +# they only pin that the old '#'-prefix filters were too broad. +mkfixture raw_string_marker ' + sysio_set_contract_name(r); + const char* s = R"d( +# 1 "fake")d"; sysio_set_contract_name(c); + (void)s; + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# A marker-shaped SUFFIX: a raw string OPENING on the same line as the second call, whose +# remainder is a well-formed marker. This pins the leading `^`. Without it the line matches on +# its tail, sed deletes the whole line, and the second call goes with it -- 3 occurrences drop +# to 2 and the checker accepts. Every other raw-string row closes at column 1, so none of them +# can pin the start anchor. +mkfixture marker_tail ' + sysio_set_contract_name(r); + sysio_set_contract_name(c); const char* s = R"z(# 1 "a" +)z"; + (void)s; + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# The filename model from ABOVE, where marker_escaped_ok only pins it from below. A closing +# delimiter, the second call, and a later quoted string all on one line: model the filename as +# `.*` and the greedy match runs from the first quote to the last, swallowing the call. The +# real grammar stops at the unescaped quote that ends the filename, so the line is not a marker +# and survives intact. +mkfixture marker_greedy ' + sysio_set_contract_name(r); + const char* s = R"d( +# 1 "x)d"; sysio_set_contract_name(c); const char* t = "y" + ; + (void)s; (void)t; + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +mkfixture missing_entirely ' + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +mkfixture after_dispatch ' if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); } + sysio_set_contract_name(r);' + +# The positive controls: correct dispatches whose text is awkward. +mkfixture spaced_ok ' + sysio_set_contract_name (r); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# A #line directive whose filename carries an ESCAPED QUOTE, immediately before the setter. +# cdt-cpp -E re-emits it as the legal marker `# 7 "a\"b.cpp"`; a filter modelling the filename +# as `[^"]*` cannot match that, leaves the marker in the normalised source, and then reads it +# as the start of apply()'s first statement -- rejecting a dispatch that is correct. +mkfixture marker_escaped_ok ' +#line 7 "a\"b.cpp" + sysio_set_contract_name(r); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# An #include immediately before the setter. CDT emits an ENTER and a RETURN marker for it -- +# `# 1 "./empty_header.hpp" 1` and `# N "fixture.cpp" 2` -- between the opening brace and the +# first statement, which is the only shape that pins the trailing `([[:space:]]+[0-9]+)*`: +# drop that group and neither line is a marker any more, both survive normalisation, and the +# first is read as apply()'s first statement. Every other marker in these fixtures is +# flagless, so nothing else covers it. +cat > "${WORK}/empty_header.hpp" <<'HDREOF' +#pragma once +HDREOF +# Reaches the import through an asm label whose spelling is split across two string literals, +# so no second contiguous `sysio_set_contract_name` appears in the preprocessed text. Compiles +# under the driver; the text checker ACCEPTS it, which is the whole reason section 4 exists. +mkfixture asm_label ' + sysio_set_contract_name(r); + extern void again(uint64_t) __asm__("sysio_set_" "contract_name"); + again(c); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +# The same alias, reached through a volatile function pointer. cdt-cpp emits ONE direct setter +# relocation, then R_WASM_TABLE_INDEX_SLEB for the address and a call_indirect -- so a scan of +# call relocations counts one call, first, and accepts, while apply() overwrites the receiver +# with the code. This is the row that pins the indirect ban. +mkfixture indirect_alias ' + sysio_set_contract_name(r); + extern void again(uint64_t) __asm__("sysio_set_" "contract_name"); + using setter_fn = void (*)(uint64_t); + setter_fn volatile fp = again; + fp(c); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' +mkfixture marker_flags_ok ' +#include "empty_header.hpp" + sysio_set_contract_name(r); + if (c == r) { __sysio_action_go_x(r, c); } else { __sysio_notify_on_x(r, c); }' + +# Compiled by the DRIVER, not a host clang: a fixture must be legal in the translation unit +# that actually ships, and the driver supplies the wasm32 target and the CDT include graph. +# (`-c` to an object we discard; the driver has no -fsyntax-only.) Returns non-zero if the +# fixture did not compile, having already reported the failure itself. +compile_fixture() { + if ( cd "$WORK" && "$CDT_CPP" -c "fixture_$1.cpp" -o "fixture_$1.o" ) \ + > "${WORK}/fixture_$1.log" 2>&1; then + return 0 + fi + fail "fixture compiles: $1" + sed 's/^/ /' "${WORK}/fixture_$1.log" + return 1 +} + +# Decide whether one counterexample was CAUGHT, which is the outcome the table requires: +# rejected, having been read. Returns 0 for that, and non-zero -- echoing why -- for either +# other outcome. Acceptance is the obvious failure; an infrastructure error is the quiet one, +# and folding it in with `-ne 0` would report a full green sweep on a machine where the driver +# cannot run at all. Section 3 pins that this distinction is made HERE, at the call site, and +# not only inside check_dispatch. +classify_counterexample() { # $1=fixture file + run_check "$1" + if [ "$VERDICT_STATUS" -eq "$REJECTED" ]; then + return 0 + fi + if [ "$VERDICT_STATUS" -eq 0 ]; then + echo "accepted a dispatch that breaks the contract:" + sed 's/^/ /' "$1" + else + echo "no verdict was reached: ${VERDICT}" + fi + return 1 +} + +for bad in code_not_receiver inside_branch signature_line double_call comment_split \ + spliced_call spliced_ws raw_string_comment raw_string_hash raw_string_hash_num \ + raw_string_marker marker_tail marker_greedy target_conditional macro_expanded \ + missing_entirely after_dispatch; do + compile_fixture "$bad" || continue + reason=""; caught=0 + reason="$(classify_counterexample "${WORK}/fixture_${bad}.cpp")" || caught=$? + if [ "$caught" -eq 0 ]; then + pass "the checker rejects: ${bad}" + else + fail "the checker rejects: ${bad}" + printf '%s\n' "$reason" | sed 's/^/ /' + fi +done + +# ...and must not reject a well-formed one that merely looks unusual. +for good in spaced_ok marker_escaped_ok marker_flags_ok; do + compile_fixture "$good" || continue + run_check "${WORK}/fixture_${good}.cpp" + if [ "$VERDICT_STATUS" -eq 0 ]; then + pass "the checker accepts: ${good}" + else + fail "the checker accepts: ${good}" + echo " ${VERDICT}" + fi +done + +# --- 3. a failing preprocessor is an infrastructure error, not a verdict -------------------- +# +# Stand in for the driver with something that prints, then fails. Both shapes below used to be +# reported as verdicts, because check_dispatch never looked at the status: plausible output +# read as acceptance, and truncated output read as a rejection -- which, in the loop above, +# reads as a PASS on a machine where the toolchain is broken. +cat > "${WORK}/fake_cdt_cpp" <<'EOF' +#!/bin/bash +# Prints a canned payload and exits with a canned status, both read from files beside it, so +# one stand-in covers every shape of preprocessor failure. +cat "$(dirname "$0")/fake_pp_out" +exit "$(cat "$(dirname "$0")/fake_pp_status")" +EOF +chmod +x "${WORK}/fake_cdt_cpp" +printf '73\n' > "${WORK}/fake_pp_status" + +# Output that WOULD be accepted, so only the status can distinguish it. +cat > "${WORK}/fake_pp_out" <<'EOF' +extern "C" { + void sysio_set_contract_name(unsigned long long n); + void apply(unsigned long long r, unsigned long long c, unsigned long long a) { + sysio_set_contract_name(r); + } +} +EOF + +real_cdt_cpp="$CDT_CPP" +CDT_CPP="${WORK}/fake_cdt_cpp" +for shape in acceptable_output truncated_output; do + [ "$shape" = truncated_output ] && : > "${WORK}/fake_pp_out" + run_check "${WORK}/c.cpp" + if [ "$VERDICT_STATUS" -eq "$INFRA_ERROR" ]; then + pass "a failing preprocessor reaches no verdict: ${shape}" + else + fail "a failing preprocessor reaches no verdict: ${shape}" + echo " status ${VERDICT_STATUS}: ${VERDICT}" + fi + + # ...and the counterexample table must not read that as a catch. This is the half that a + # status alone does not buy: every row above reports a PASS for any non-zero status unless + # the call site separates the two, so a driver that cannot run would sweep the table green. + reason=""; caught=0 + reason="$(classify_counterexample "${WORK}/fixture_code_not_receiver.cpp")" || caught=$? + if [ "$caught" -ne 0 ]; then + pass "a counterexample is not counted as caught: ${shape}" + else + fail "a counterexample is not counted as caught: ${shape}" + echo " the table reported a catch though no verdict was reached" + fi +done +CDT_CPP="$real_cdt_cpp" + +# --- 4. the same requirement over the emitted object --------------------------------------- +# +# Exercised the way the text checker is: against the real generated dispatch, against a +# positive control, and against the counterexample the text checker cannot see. +# Does $1 meet expectation $2? Returns 0 when it does, and non-zero -- echoing why -- when it +# does not. EXACTLY the expected status, not merely non-zero: an analyser that could not run +# returns INFRA_ERROR, and counting that as the rejection a reject row expects is how a broken +# llvm-objdump sweeps this section green. The same trap the preprocessor path already avoids, +# and it is pinned below the same way. +classify_symbols() { # $1=object $2=accept|reject + local verdict status=0 want=0 + if [ "$2" = reject ]; then want="$REJECTED"; fi + verdict="$(check_dispatch_symbols "$1")" || status=$? + if [ "$status" -eq "$want" ]; then + return 0 + fi + if [ "$status" -eq "$INFRA_ERROR" ]; then + echo "no verdict was reached: ${verdict}" + else + echo "${verdict}" + fi + return 1 +} + +run_symbols() { # $1=label $2=object $3=expect: accept|reject + local reason="" ok=0 + reason="$(classify_symbols "$2" "$3")" || ok=$? + if [ "$ok" -eq 0 ]; then + pass "the symbol check ${3}s: $1" + else + fail "the symbol check ${3}s: $1" + echo " ${reason}" + fi +} + +# The real dispatch, compiled on its own: the contract build above already links it, but the +# object is what carries the relocations. +if ( cd "$WORK" && "$CDT_CPP" -c "$DISPATCH" -o real_dispatch.o ) > "${WORK}/real.log" 2>&1; then + run_symbols "the generated dispatch" "${WORK}/real_dispatch.o" accept +else + fail "the generated dispatch compiles on its own" + sed 's/^/ /' "${WORK}/real.log" +fi + +run_symbols "a space before the paren" "${WORK}/fixture_spaced_ok.o" accept + +# The setter present exactly once but AFTER the dispatch. Its object is already built by the +# counterexample loop, and it is what pins the "first call" branch -- without it, deleting that +# branch leaves this section green. +run_symbols "the setter after the dispatch" "${WORK}/fixture_after_dispatch.o" reject + +# A compile failure here must not read as the rejection this row expects, so the check only +# runs once the object exists. +for indirect in asm_label indirect_alias; do + if compile_fixture "$indirect"; then + run_symbols "reaching the same import: ${indirect}" "${WORK}/fixture_${indirect}.o" reject + fi +done + +# ...and a failing analyser is an infrastructure error here too, not a verdict. Without that, +# every reject row above passes on a machine where llvm-objdump cannot run: a truncated dump +# reads as a rejection, which is exactly what those rows are looking for. +cat > "${WORK}/fake_objdump" <<'EOF' +#!/bin/bash +# Prints a canned payload and exits with a canned status, both read from files beside it. +cat "$(dirname "$0")/fake_od_out" +exit "$(cat "$(dirname "$0")/fake_od_status")" +EOF +chmod +x "${WORK}/fake_objdump" +printf '73\n' > "${WORK}/fake_od_status" +# A dump that WOULD be accepted, so only the status can distinguish it. +"$LLVM_OBJDUMP" -dr "${WORK}/fixture_spaced_ok.o" > "${WORK}/fake_od_out" 2>/dev/null + +real_objdump="$LLVM_OBJDUMP" +LLVM_OBJDUMP="${WORK}/fake_objdump" +for shape in acceptable_output truncated_output; do + [ "$shape" = truncated_output ] && : > "${WORK}/fake_od_out" + + sym_verdict=""; sym_status=0 + sym_verdict="$(check_dispatch_symbols "${WORK}/fixture_spaced_ok.o")" || sym_status=$? + if [ "$sym_status" -eq "$INFRA_ERROR" ]; then + pass "a failing analyser reaches no verdict: ${shape}" + else + fail "a failing analyser reaches no verdict: ${shape}" + echo " status ${sym_status}: ${sym_verdict}" + fi + + # ...and a reject row must not be satisfied by it, which is the half a status alone does + # not buy. + sym_reason=""; sym_ok=0 + sym_reason="$(classify_symbols "${WORK}/fixture_spaced_ok.o" reject)" || sym_ok=$? + if [ "$sym_ok" -ne 0 ]; then + pass "a reject row is not satisfied by an analyser failure: ${shape}" + else + fail "a reject row is not satisfied by an analyser failure: ${shape}" + fi +done +LLVM_OBJDUMP="$real_objdump" + +# ...and the text checker really does miss that one, which is why both run. Reported rather +# than asserted: a future text checker strong enough to catch it should not fail this suite. +run_check "${WORK}/fixture_asm_label.cpp" +if [ "$VERDICT_STATUS" -eq 0 ]; then + echo " NOTE: the text checker accepts asm_label, as expected -- only the symbol check sees it" +else + echo " NOTE: the text checker now also rejects asm_label (${VERDICT})" +fi + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ] diff --git a/tests/unit/kv_multi_index_tests.cpp b/tests/unit/kv_multi_index_tests.cpp new file mode 100644 index 000000000..1a72c52ce --- /dev/null +++ b/tests/unit/kv_multi_index_tests.cpp @@ -0,0 +1,359 @@ +/** + * @file + * @copyright defined in sysio.cdt/LICENSE.txt + * + * Native coverage for sysio::multi_index's mutation guards. + * + * These assertions exist natively, rather than only in tests/integration, because + * ENABLE_INTEGRATION_TESTS defaults OFF and the CI workflow does not enable it -- an + * integration-only regression leaves required CI green when the guard is deleted. The + * equivalent on-chain cases live in tests/integration/multi_index_tests.cpp and remain the + * real-runtime coverage. + * + * Two guards are under test, both restored to match upstream multi_index: + * + * 1. A duplicate primary key aborts. kv_set is an upsert, so without the check the row is + * silently overwritten and store_secondaries strands the previous mapping. + * 2. Mutating through a handle opened on another account aborts. Reads take a `code` + * argument and honour it; kv_set, kv_erase and kv_idx_store have none and always land + * on the receiver. table_id derives from the table NAME alone, so a foreign-code + * mutation probes their table and writes the receiver's row of the same name. + * + * The cases come in two kinds. The REJECTING ones abort before any write, so their store is + * seeded directly rather than through emplace. The ALLOWING ones -- an owned handle doing + * emplace, modify and erase -- run the mutation through, so the mock also serves kv_set, + * kv_erase and the iterator reads that emplace's closing find() performs. Both kinds matter: + * without the allowing ones, a guard that rejected everything would satisfy the suite. + * + * No secondary-index intrinsic is needed: the table under test declares no indices, so + * store/remove/update_secondaries fold to nothing. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace sysio; +using namespace sysio::native; + +// The generated dispatcher records the receiver here at the top of apply(); the native +// dispatch does not, which is what the fallback in receiving_account() is for. Driving it +// directly lets both branches be exercised. +extern "C" void sysio_set_contract_name(uint64_t n); + +namespace { + +struct record { + uint64_t id; + uint64_t sec; + + uint64_t primary_key() const { return id; } + uint64_t get_secondary() const { return sec; } + + SYSLIB_SERIALIZE(record, (id)(sec)) +}; + +using table_t = sysio::multi_index<"records"_n, record>; + +/// A caller's key wrapper. find/get/require_find take uint64_t and so accept one of these +/// through a single user-defined conversion; the bounds must not be narrower than they are. +struct wrapped_key { + uint64_t v; + constexpr operator uint64_t() const { return v; } // NOLINT(google-explicit-constructor) +}; + +/// Convertible to BOTH parameter types. Against a single uint64_t parameter this selected the +/// uint64_t conversion; against the overload pair it is ambiguous. Pinned so the documented +/// cost stays a documented cost rather than being rediscovered as a surprise. +struct dual_key { + constexpr operator uint64_t() const { return 3; } // NOLINT(google-explicit-constructor) + operator name() const { return "alice"_n; } // NOLINT(google-explicit-constructor) +}; + +/// Is `t.lower_bound(A)` well-formed? +template +struct callable_with : std::false_type {}; +template +struct callable_with().lower_bound(std::declval()))>> + : std::true_type {}; + +constexpr uint32_t records_tid = sysio::kv::compute_table_id("records"_n.value); + +// Mirrors the asymmetry under test: kv_contains and kv_get honour `code`, while kv_set and +// kv_erase have no such parameter and always land on store().receiver. Rows are therefore +// keyed by the account that holds them, which is how a misdirected write is made visible. +struct mock_kv { + using row_key = std::tuple; // code, table_id, key + std::map rows; + uint64_t receiver = 0; + uint32_t sets = 0; // 0 unless a case expects the write to be allowed + std::string it_key; // key the one live iterator was positioned at + uint32_t erases = 0; + + void reset(uint64_t who) { rows.clear(); receiver = who; sets = 0; erases = 0; it_key.clear(); } +}; + +mock_kv& store() { static mock_kv inst; return inst; } + +/// The 16-byte primary key multi_index builds: [scope:8B BE][pk:8B BE]. +std::string pk_key(uint64_t scope, uint64_t pk) { + std::string k(16, '\0'); + for (int i = 7; i >= 0; --i) { k[i] = char(scope & 0xFF); scope >>= 8; } + for (int i = 7; i >= 0; --i) { k[8 + i] = char(pk & 0xFF); pk >>= 8; } + return k; +} + +void install_intrinsics() { + intrinsics::set_intrinsic( + []() -> capi_name { return store().receiver; }); + + intrinsics::set_intrinsic( + [](uint32_t table_id, capi_name code, const void* key, uint32_t key_size) -> int32_t { + auto k = std::string(static_cast(key), key_size); + return store().rows.count(mock_kv::row_key{code, table_id, k}) ? 1 : 0; + }); + + // No code parameter: a write always lands on the receiver, never on the handle's code. + // Recorded under store().receiver so a misdirected write is observable as a row under the + // wrong account, not merely as a count. + intrinsics::set_intrinsic( + [](uint32_t table_id, uint64_t, const void* key, uint32_t key_size, + const void* val, uint32_t val_size) -> int64_t { + ++store().sets; + store().rows[mock_kv::row_key{store().receiver, table_id, + std::string(static_cast(key), key_size)}] = + std::string(static_cast(val), val_size); + return 0; + }); + + // No code parameter here either: an erase always lands on the receiver. + intrinsics::set_intrinsic( + [](uint32_t table_id, const void* key, uint32_t key_size) -> int64_t { + ++store().erases; + store().rows.erase(mock_kv::row_key{store().receiver, table_id, + std::string(static_cast(key), key_size)}); + return 0; + }); + + // emplace() returns find(pk), so a write that is allowed through walks the iterator path. + // Exactly one iterator is ever live in these cases, so remembering the key it was + // positioned at is enough to serve a real key/value pair rather than a stub -- the + // returned iterator is genuinely valid, not merely non-crashing. + intrinsics::set_intrinsic( + [](uint32_t, capi_name, const void*, uint32_t) -> uint32_t { return 1; }); + intrinsics::set_intrinsic([](uint32_t) {}); + intrinsics::set_intrinsic([](uint32_t) -> int32_t { return 0; }); + intrinsics::set_intrinsic( + [](uint32_t, const void* key, uint32_t key_size) -> int32_t { + store().it_key.assign(static_cast(key), key_size); + return 0; + }); + + // Serve from the store, so a key or value the contract never wrote cannot be read back. + auto serve = [](const std::string& src, uint32_t offset, void* dest, uint32_t dest_size, + uint32_t* actual_size) -> int32_t { + if (offset > src.size()) return -1; + *actual_size = static_cast(src.size() - offset); + const uint32_t n = *actual_size < dest_size ? *actual_size : dest_size; + std::memcpy(dest, src.data() + offset, n); + return 0; + }; + intrinsics::set_intrinsic( + [serve](uint32_t, uint32_t off, void* d, uint32_t ds, uint32_t* as) -> int32_t { + return serve(store().it_key, off, d, ds, as); + }); + // Reached: emplace's closing find() constructs an iterator, whose load_current() reads the + // key and then the value. Serving from the store rather than stubbing means a row that + // landed under the wrong key cannot be read back as if it were correct. + intrinsics::set_intrinsic( + [serve](uint32_t, uint32_t off, void* d, uint32_t ds, uint32_t* as) -> int32_t { + auto it = store().rows.find(mock_kv::row_key{store().receiver, records_tid, + store().it_key}); + if (it == store().rows.end()) return -1; + return serve(it->second, off, d, ds, as); + }); +} + +/// The account whose table is under test is never this one. When the dispatcher-global path +/// is being exercised, the current_receiver intrinsic is pointed here instead, so the two +/// branches of receiving_account() cannot return the same answer. +constexpr uint64_t decoy_receiver = "carol"_n.value; + +/// Seed `owner`'s table with pk, and install the mocks. +/// +/// @param dispatcher_sets_name mirrors the generated dispatcher recording the receiver in +/// the sysio_contract_name global. When true, the mocked current_receiver +/// deliberately returns decoy_receiver rather than `owner`: a guard that consulted +/// the intrinsic instead of the global would then get the wrong account and the case +/// would fail. Pointing both at `owner` -- as this did originally -- makes the two +/// branches indistinguishable, and deleting the global fast path leaves the suite +/// green. When false the global is 0, as SYSIO_DISPATCH and the native dispatch leave +/// it, and the intrinsic is the only source. +void arrange(uint64_t owner, uint64_t scope, uint64_t pk, bool dispatcher_sets_name) { + store().reset(dispatcher_sets_name ? decoy_receiver : owner); + store().rows[mock_kv::row_key{owner, records_tid, pk_key(scope, pk)}] = "row"; + install_intrinsics(); + sysio_set_contract_name(dispatcher_sets_name ? owner : 0); +} + +} // namespace + +// A duplicate primary key must abort rather than upsert. +SYSIO_TEST_BEGIN(duplicate_primary_key_rejected) + for (bool via_global : {true, false}) { + arrange("alice"_n.value, "alice"_n.value, 1, via_global); + table_t t("alice"_n, "alice"_n.value); + + CHECK_ASSERT( "object with the same primary key already exists", + ([&]() { t.emplace("alice"_n, [](auto& r) { r.id = 1; r.sec = 7; }); }) ) + CHECK_EQUAL( store().sets, 0u ) + } +SYSIO_TEST_END + +// Mutating through a foreign-code handle must abort. The receiver holds pk=1 and the foreign +// account does not, so the duplicate probe alone would pass -- this is precisely the case +// where the old code upserted the receiver's row. +SYSIO_TEST_BEGIN(foreign_code_handle_cannot_mutate) + for (bool via_global : {true, false}) { + arrange("alice"_n.value, "alice"_n.value, 1, via_global); + table_t foreign("bob"_n, "alice"_n.value); + record r{1, 7}; + + CHECK_ASSERT( "cannot create objects in table of another contract", + ([&]() { foreign.emplace("alice"_n, [](auto& o) { o.id = 1; o.sec = 7; }); }) ) + CHECK_ASSERT( "cannot modify objects in table of another contract", + ([&]() { foreign.modify(r, "alice"_n, [](auto& o) { o.sec = 9; }); }) ) + CHECK_ASSERT( "cannot erase objects in table of another contract", + ([&]() { foreign.erase(r); }) ) + + // The whole point: the receiver's row was never touched. The VALUE comparison is what + // carries that -- a misdirected emplace overwrites the row under the same key, so the + // count() below would still be 1 and proves nothing on its own here, where nothing + // erases. It is kept as a precondition for the .at(). + CHECK_EQUAL( store().sets, 0u ) + const auto seeded = mock_kv::row_key{"alice"_n.value, records_tid, + pk_key("alice"_n.value, 1)}; + CHECK_EQUAL( store().rows.count(seeded), 1u ) + CHECK_EQUAL( store().rows.at(seeded), std::string("row") ) + } +SYSIO_TEST_END + +// A handle on the receiver's own table is unaffected by the guard. +SYSIO_TEST_BEGIN(own_table_handle_passes_the_guard) + for (bool via_global : {true, false}) { + arrange("alice"_n.value, "alice"_n.value, 1, via_global); + table_t t("alice"_n, "alice"_n.value); + + // pk=2 is absent, so neither the receiver guard nor the duplicate probe fires and the + // write goes through. Without a case that SUCCEEDS, a guard that rejected every + // mutation would satisfy the entire suite. + // + // The write lands under store().receiver, which the mock deliberately makes the decoy + // account on the global-path iteration -- that is the asymmetry under test, and it is + // also why emplace's closing find() returns end() there: it probes _code, which the + // decoy is not. Nothing here depends on the returned iterator. + t.emplace("alice"_n, [](auto& o) { o.id = 2; o.sec = 7; }); + CHECK_EQUAL( store().sets, 1u ) + const auto written = mock_kv::row_key{store().receiver, records_tid, + pk_key("alice"_n.value, 2)}; + CHECK_EQUAL( store().rows.count(written), 1u ) + // Not merely present: the row must be the one this emplace serialized, so a write + // that landed with the wrong key or wrong contents is not mistaken for success. + CHECK_EQUAL( store().rows.at(written).empty(), false ) + CHECK_EQUAL( store().rows.at(written) == std::string("row"), false ) + } +SYSIO_TEST_END + +// modify and erase must also SUCCEED on an owned handle. Without these, an inverted or +// unconditional guard on either would satisfy every required test: the foreign-code case proves +// only that they reject, and their allowed paths ran solely in the opt-in integration suite. +SYSIO_TEST_BEGIN(own_table_handle_can_modify_and_erase) + for (bool via_global : {true, false}) { + arrange("alice"_n.value, "alice"_n.value, 1, via_global); + table_t t("alice"_n, "alice"_n.value); + + // The mock seeds the row under the table's OWNER; writes land under the receiver, which + // on the global-path iteration is the decoy. Address each by the account that holds it. + const auto owned = mock_kv::row_key{"alice"_n.value, records_tid, pk_key("alice"_n.value, 1)}; + const auto written = mock_kv::row_key{store().receiver, records_tid, pk_key("alice"_n.value, 1)}; + + record r{1, 7}; + t.modify(r, "alice"_n, [](auto& o) { o.sec = 9; }); + CHECK_EQUAL( store().sets, 1u ) + // Serialized, not the seeded placeholder -- so a modify that wrote nothing, or wrote the + // wrong key, is not read as success. + CHECK_EQUAL( store().rows.count(written), 1u ) + CHECK_EQUAL( store().rows.at(written) == std::string("row"), false ) + + // erase() removes the row it addresses. It is keyed the same way kv_set is, so it lands + // on the receiver too. + t.erase(r); + CHECK_EQUAL( store().erases, 1u ) + CHECK_EQUAL( store().rows.count(written), 0u ) + // The owner's seeded row is untouched on the decoy iteration, gone on the fallback one + // where receiver == owner -- either way the erase hit exactly the namespace it wrote to. + CHECK_EQUAL( store().rows.count(owned), via_global ? 1u : 0u ) + } +SYSIO_TEST_END + +// The primary bounds take a `name` as well as a uint64_t, matching the two-overload shape +// find/require_find/get have always used. Compile-time only -- nothing here is evaluated. +SYSIO_TEST_BEGIN(primary_bounds_accept_uint64_and_name) + using itr_t = table_t::const_iterator; + + // Pin BOTH overloads by exact signature. A named static_cast resolves an overload set, so + // these fail to compile if either parameter type changes -- which is what would happen if + // the uint64_t parameter were ever swapped for a converting proxy again. That also + // demonstrates the documented escape hatch for taking a member pointer. + constexpr auto lb_u64 = static_cast(&table_t::lower_bound); + constexpr auto lb_name = static_cast(&table_t::lower_bound); + constexpr auto ub_u64 = static_cast(&table_t::upper_bound); + constexpr auto ub_name = static_cast(&table_t::upper_bound); + static_assert(lb_u64 && lb_name && ub_u64 && ub_name, "both bound overloads must exist"); + + // A real uint64_t parameter, so every conversion the base performed is unchanged. The + // braced forms in particular must stay unambiguous: name's uint64_t constructor is + // explicit, so name is never viable for a braced integer. + // declval, not a dereferenced null: these appear only in unevaluated operands, and the + // test body itself must stay well-defined at run time. +#define LB(expr) decltype(std::declval().lower_bound expr) +#define UB(expr) decltype(std::declval().upper_bound expr) + static_assert(std::is_same_v, "uint64_t"); + static_assert(std::is_same_v, "a name"); + // The braced-literal case is the point of the assertion, so the diagnostic it provokes is + // suppressed rather than avoided. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wbraced-scalar-init" + static_assert(std::is_same_v, "braced literal"); +#pragma clang diagnostic pop + static_assert(std::is_same_v, "empty brace, key zero"); + static_assert(std::is_same_v, "uint64-convertible wrapper"); + static_assert(std::is_same_v, "a name"); + + // The documented costs. A dual-convertible wrapper is ambiguous here, as it already was + // for find/get/require_find -- consistent with the siblings, but a source break against + // the single uint64_t parameter, so it is pinned rather than left to be rediscovered. + static_assert(callable_with::value && callable_with::value && + callable_with::value, "the accepted domain must stay callable"); + static_assert(!callable_with::value, + "a uint64_t-and-name-convertible wrapper is ambiguous, as it is for find()"); +#undef LB +#undef UB +SYSIO_TEST_END + +int main(int argc, char* argv[]) { + bool verbose = false; + SYSIO_TEST(duplicate_primary_key_rejected) + SYSIO_TEST(foreign_code_handle_cannot_mutate) + SYSIO_TEST(own_table_handle_passes_the_guard) + SYSIO_TEST(own_table_handle_can_modify_and_erase) + SYSIO_TEST(primary_bounds_accept_uint64_and_name) + return has_failed(); +} diff --git a/tests/unit/test_contracts/multi_index_tests.cpp b/tests/unit/test_contracts/multi_index_tests.cpp index c76360ecd..614baa658 100644 --- a/tests/unit/test_contracts/multi_index_tests.cpp +++ b/tests/unit/test_contracts/multi_index_tests.cpp @@ -342,6 +342,106 @@ namespace _test_multi_index return table; } + // Duplicate primary key must be rejected. + // + // On Antelope the guard was db_store_i64's, at the chain layer, and it was lost when + // the legacy DB was removed. kv_set is an upsert, so without an explicit check the row + // is silently overwritten and store_secondaries -- an unconditional kv_idx_store -- + // strands the previous (sec_key -> pri_key) mapping. Verified against the real runtime: + // before the guard, a lookup of the OLD secondary value still resolved to this row + // after it had been overwritten with a new one. + template + void idx64_duplicate_emplace(sysio::name receiver) + { + typedef record_idx64 record; + sysio::kv_multi_index>> + table(receiver, receiver.value); + auto payer = receiver; + + table.emplace(payer, [&](auto& r) { r.id = 1; r.sec = "aaa"_n.value; }); + + // Changing the secondary value is what made the stale mapping observable. + table.emplace(payer, [&](auto& r) { r.id = 1; r.sec = "bbb"_n.value; }); + } + + // A `name` primary key alongside a secondary index. store/remove/update_secondaries feed + // primary_key() straight to pk_to_bytes(uint64_t), so before to_pk_uint64 was applied + // there this combination did not compile at all. + struct record_name_pk + { + sysio::name owner; + uint64_t sec; + + sysio::name primary_key() const { return owner; } + uint64_t get_secondary() const { return sec; } + + SYSLIB_SERIALIZE(record_name_pk, (owner)(sec)) + }; + + template + void name_pk_secondaries(sysio::name receiver) + { + typedef record_name_pk record; + sysio::kv_multi_index>> + table(receiver, receiver.value); + auto payer = receiver; + + table.emplace(payer, [&](auto& r) { r.owner = "alice"_n; r.sec = 10; }); + table.emplace(payer, [&](auto& r) { r.owner = "bob"_n; r.sec = 20; }); + table.emplace(payer, [&](auto& r) { r.owner = "charlie"_n; r.sec = 30; }); + + // A name goes straight to the bounds, as it already did to find/get/require_find. + auto lb = table.lower_bound("bob"_n); + sysio::check(lb != table.end() && lb->owner == "bob"_n, + "name_pk_secondaries - lower_bound(name) did not land on bob"); + + auto ub = table.upper_bound("bob"_n); + sysio::check(ub != table.end() && ub->owner == "charlie"_n, + "name_pk_secondaries - upper_bound(name) did not land on charlie"); + + // The uint64_t form is unchanged, and must agree with the name form. + auto lb_raw = table.lower_bound("bob"_n.value); + sysio::check(lb_raw != table.end() && lb_raw->owner == "bob"_n, + "name_pk_secondaries - lower_bound(uint64_t) regressed"); + + // The secondary index must resolve back to the name-keyed row: this is the path + // to_pk_uint64 fixed. modify() rewrites the mapping, erase() removes it. + auto sec = table.template get_index<"bysecondary"_n>(); + auto sitr = sec.find(20); + sysio::check(sitr != sec.end() && sitr->owner == "bob"_n, + "name_pk_secondaries - secondary lookup did not resolve to bob"); + + table.modify(*sitr, payer, [&](auto& r) { r.sec = 25; }); + sysio::check(sec.find(20) == sec.end(), + "name_pk_secondaries - modify left the old secondary mapping behind"); + auto moved = sec.find(25); + sysio::check(moved != sec.end() && moved->owner == "bob"_n, + "name_pk_secondaries - modify did not install the new secondary mapping"); + + table.erase(*moved); + sysio::check(sec.find(25) == sec.end(), + "name_pk_secondaries - erase left the secondary mapping behind"); + sysio::check(table.find("bob"_n.value) == table.end(), + "name_pk_secondaries - erase did not remove the primary row"); + } + + // Mutating through a handle opened on another account must abort. Reads honour the + // handle's code; kv_set/kv_idx_store do not and always land on the receiver, and table_id + // derives from the table name alone -- so without the guard this writes the receiver's + // own row of the same name. Upstream multi_index rejects it. + template + void foreign_code_mutation(sysio::name receiver) + { + typedef record_idx64 record; + sysio::kv_multi_index>> + foreign("bob"_n, receiver.value); + + foreign.emplace(receiver, [&](auto& r) { r.id = 1; r.sec = 1; }); + } + } /// _test_multi_index class [[sysio::contract]] test_multi_index : public sysio::contract @@ -354,6 +454,18 @@ class [[sysio::contract]] test_multi_index : public sysio::contract _test_multi_index::idx64_check_without_storing<"indextable2"_n.value>( get_self() ); } + [[sysio::action("s1foreign")]] void foreign_code_mutation() { + _test_multi_index::foreign_code_mutation<"foreigntbl"_n.value>(get_self()); + } + + [[sysio::action("s1namepk")]] void name_pk_secondaries() { + _test_multi_index::name_pk_secondaries<"namepktable"_n.value>(get_self()); + } + + [[sysio::action("s1dupidx")]] void idx64_duplicate_emplace() { + _test_multi_index::idx64_duplicate_emplace<"duptable1"_n.value>(get_self()); + } + [[sysio::action("s1store")]] void idx64_store_only() { _test_multi_index::idx64_store_only<"indextable1"_n.value>(get_self()); }