From f45844b63a3758f17990c227aa585e391303cee4 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 15 Sep 2026 15:54:27 -0500 Subject: [PATCH 01/12] feat(sysiolib): add sysio::slug_name and make it an abigen builtin slug_name is the packed registry-code identifier the depot keys its v6 registry tables on, and it lived only in wire-sysio's contracts/sysio.opp.common as a hand-rolled struct. It belongs beside name: both are basic_name instantiations over their own traits, and the abigen builtins entry that lets a field carry the bare `slug_name` ABI type name has to live in this repo regardless. The builtins entry is what stops the alias emitting a typedef. abi_serializer resolves typedefs BEFORE its builtin lookup, so an alias without the entry would silently revert every slug field to `{"value": N}` with no error anywhere. CDT_REFLECT moves onto basic_name itself rather than the instantiation: to_key's generic reflects instead of consulting operator<<, so a basic_name reaching it without reflection encodes a zero-byte key. Declaring it on the template covers name, slug_name and any future traits at once. Change-Id: I2c0e4d00126b576877c38c91464a24302ef2d971 --- libraries/sysiolib/core/sysio/basic_name.hpp | 7 ++ libraries/sysiolib/core/sysio/slug_name.hpp | 84 +++++++++++++++ plugins/sysio/gen.hpp | 1 + tests/CMakeLists.txt | 1 + tests/unit/CMakeLists.txt | 1 + tests/unit/slug_name_tests.cpp | 101 +++++++++++++++++++ 6 files changed, 195 insertions(+) create mode 100644 libraries/sysiolib/core/sysio/slug_name.hpp create mode 100644 tests/unit/slug_name_tests.cpp diff --git a/libraries/sysiolib/core/sysio/basic_name.hpp b/libraries/sysiolib/core/sysio/basic_name.hpp index d2985de0b..18392a45a 100644 --- a/libraries/sysiolib/core/sysio/basic_name.hpp +++ b/libraries/sysiolib/core/sysio/basic_name.hpp @@ -32,6 +32,7 @@ */ #include "check.hpp" +#include "reflect.hpp" #include "serialize.hpp" #include @@ -150,6 +151,12 @@ struct basic_name { friend constexpr bool operator==( basic_name a, basic_name b ) = default; SYSLIB_SERIALIZE( basic_name, (value) ) + // Bluegrass reflection, needed by CDT's to_key: its generic dispatches on + // is_floating_point / is_integral / is_enum and otherwise reflects, never + // consulting operator<<. Without this a basic_name reaching to_key reflects + // as invalid_fields and silently encodes a ZERO-BYTE key. Declared here + // rather than per-instantiation so every traits specialisation is covered. + CDT_REFLECT(value); private: // --- symbol width: minimal bits to index the alphabet --- diff --git a/libraries/sysiolib/core/sysio/slug_name.hpp b/libraries/sysiolib/core/sysio/slug_name.hpp new file mode 100644 index 000000000..9aa67f048 --- /dev/null +++ b/libraries/sysiolib/core/sysio/slug_name.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include "basic_name.hpp" +#include "name.hpp" // for sysio::detail::to_const_char_arr + +#include + +namespace sysio { + + /** + * @defgroup slug_name + * @ingroup core + * @ingroup types + * @brief Packed registry-code identifier + */ + + /// Alphabet + length traits for the registry-code encoding: up to 8 symbols + /// over [A-Z0-9_]. Drives sysio::basic_name. + /// + /// Byte-identical with the host-side fc::slug_name, which instantiates + /// fc::basic_name over the same alphabet, length, terminator rule and + /// packing direction. The two traits structs are the whole specification — + /// keep them diffable line for line. + struct slug_name_traits { + /// 8, not the 10 that would fill 64 bits: 8 symbols x 6 bits = 48, so + /// every encoded value stays in [0, 2^48) — under JS Number's 2^53 safe + /// integer limit, letting TS consumers use `number` rather than bigint. + static constexpr int max_len = 8; + + /// Symbol 0 is the '\0' pad/terminator; 1-26 = A-Z, 27-36 = 0-9, 37 = '_'. + /// Held as a named array so the length comes from sizeof — a string_view + /// built straight from the literal would stop at the leading NUL. + static constexpr char alphabet_storage[] = + "\0ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; + static constexpr std::string_view alphabet{ alphabet_storage, + sizeof(alphabet_storage) - 1 }; + + /// A symbol-0 slot TERMINATES the string, unlike name's '.' which is an + /// ordinary interior character. This is why a slug_name is not total over + /// uint64: every value below 2^42 has a zero in the char[0] slot and so + /// decodes to the empty string. + static constexpr bool zero_terminates = true; + + /// MSB-first: char[0] occupies bits [42..47]. This is what gives the + /// packed value its grouping property — a shared textual prefix is a + /// shared leading bit prefix, so prefix-related codes are contiguous in + /// key order and retrievable as a range. + static constexpr basic_name_endianness packing = basic_name_endianness::MSB; + + static constexpr const char* bad_char_message = + "character is not in allowed character set for slug_names ([A-Z0-9_])"; + static constexpr const char* too_long_message = + "string is too long to be a valid slug_name"; + static constexpr const char* bad_final_symbol_message = + "final character in slug_name does not fit its packed slot"; + }; + + /// Packed registry-code identifier — up to 8 symbols over [A-Z0-9_]. + /// + /// Declared as an alias rather than a derived struct on purpose. abigen + /// matches builtins on the namespace-stripped written spelling, and + /// `slug_name` is in that set (plugins/sysio/gen.hpp), so no typedef and no + /// struct_def is emitted and a field declared `sysio::slug_name` carries the + /// bare ABI type name. A derived struct would reflect as a base with zero + /// declared fields the moment it stopped being a builtin. + using slug_name = basic_name; + +} // namespace sysio + +/** + * Compile-time slug_name literal: `"ETH"_s`, `"LIQSOL"_s`. Validation happens + * in basic_name's constexpr constructor — a character outside [A-Z0-9_], or + * more than 8 characters, fails the constant evaluation. Mirrors the shape of + * sysio::name's `_n` literal, including its global scope. + */ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wgnu-string-literal-operator-template" +template +inline constexpr sysio::slug_name operator""_s() { + constexpr auto x = sysio::slug_name{ + std::string_view{ sysio::detail::to_const_char_arr::value, sizeof...(Str) } }; + return x; +} +#pragma clang diagnostic pop diff --git a/plugins/sysio/gen.hpp b/plugins/sysio/gen.hpp index 4d9cfe22b..61a8a090b 100644 --- a/plugins/sysio/gen.hpp +++ b/plugins/sysio/gen.hpp @@ -859,6 +859,7 @@ struct generation_utils { "signature", "symbol", "symbol_code", + "slug_name", "asset", "extended_asset" }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5950086b8..01331cae1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,7 @@ add_unit_test( datastream_tests ) add_unit_test( fixed_bytes_tests ) add_unit_test( name_tests ) add_unit_test( basic_name_tests ) +add_unit_test( slug_name_tests ) add_unit_test( rope_tests ) add_unit_test( print_tests ) add_unit_test( protobuf_wire_tests ) diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 685da1d80..872927097 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -25,6 +25,7 @@ add_cdt_unit_test(datastream_tests) add_cdt_unit_test(fixed_bytes_tests) add_cdt_unit_test(name_tests) add_cdt_unit_test(basic_name_tests) +add_cdt_unit_test(slug_name_tests) add_cdt_unit_test(rope_tests) add_cdt_unit_test(serialize_tests) add_cdt_unit_test(symbol_tests) diff --git a/tests/unit/slug_name_tests.cpp b/tests/unit/slug_name_tests.cpp new file mode 100644 index 000000000..dab6e0227 --- /dev/null +++ b/tests/unit/slug_name_tests.cpp @@ -0,0 +1,101 @@ +/** + * @file + * @copyright defined in sysio.cdt/LICENSE.txt + * + * Unit tests for sysio::slug_name — the packed registry-code identifier, an + * instantiation of sysio::basic_name over the [A-Z0-9_] alphabet. + * + * basic_name's generic behaviour (both packing directions, the traits concept) + * is covered by basic_name_tests.cpp. What is pinned HERE is the slug policy + * itself, and above all its BYTE IDENTITY with the host-side fc::slug_name: + * the two are separate instantiations in separate toolchains, agreeing only + * because their traits agree. The expected values below are what fc produces. + * If either side's alphabet, length, terminator rule or packing direction + * drifts, these fail — which is the only mechanical guard the cross-language + * encoding has. + */ + +#include +#include + +#include +#include +#include + +using sysio::slug_name; + +// ── the traits policy, pinned ─────────────────────────────────────────────── +static_assert(sysio::slug_name_traits::max_len == 8, + "8 symbols x 6 bits = 48, keeping every value under JS Number's 2^53 limit"); +static_assert(sysio::slug_name_traits::zero_terminates, + "a symbol-0 slot terminates a slug, unlike name's '.'"); +static_assert(sysio::slug_name_traits::packing == sysio::basic_name_endianness::MSB, + "MSB-first is what gives a shared prefix a shared leading bit prefix"); +static_assert(sysio::slug_name_traits::alphabet.size() == 38, + "'\\0' pad + A-Z + 0-9 + '_'"); + +SYSIO_TEST_BEGIN(slug_name_byte_identity_with_the_host) + // These constants are fc::slug_name's output. Do not "fix" a failure by + // editing them — a mismatch means the two traits have diverged. + CHECK_EQUAL(slug_name{"A"}.value, 4398046511104ull) + CHECK_EQUAL(slug_name{"ETH"}.value, 23373212024832ull) + CHECK_EQUAL(slug_name{"WIRE"}.value, 101792956284928ull) + CHECK_EQUAL(slug_name{"SOLANA"}.value, 84606581215232ull) + CHECK_EQUAL(slug_name{"LIQSOL"}.value, 53413609783296ull) + CHECK_EQUAL(slug_name{"12345678"}.value, 125170908010659ull) + CHECK_EQUAL(slug_name{"_"}.value, 162727720910848ull) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_round_trips_canonical_spellings) + for (const char* s : {"A", "ETH", "WIRE", "SOLANA", "LIQSOL", "12345678", "_"}) { + CHECK_EQUAL(slug_name{std::string_view{s}}.to_string(), std::string{s}) + } + CHECK_EQUAL(slug_name{}.to_string(), std::string{}) + CHECK_EQUAL(slug_name{std::string_view{""}}.value, 0ull) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_literals_are_compile_time) + static_assert("ETH"_s.value == 23373212024832ull); + static_assert("LIQSOL"_s.value == 53413609783296ull); + static_assert("ETH"_s == slug_name{23373212024832ull}); + CHECK_EQUAL("WIRE"_s.value, 101792956284928ull) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_canonical_values_are_at_or_above_the_2_42_floor) + // char[0] sits at bits [42..47], so any canonical (non-empty) slug is + // >= 1<<42 — and conversely every value below the floor has a zero in the + // char[0] slot and so decodes to the empty string. This is why the host's + // JSON carrier cannot be string-only. + constexpr uint64_t floor = 1ull << 42; + CHECK_EQUAL(slug_name{"A"}.value >= floor, true) + CHECK_EQUAL(slug_name{"ETH"}.value >= floor, true) + CHECK_EQUAL(slug_name{uint64_t{7}}.to_string(), std::string{}) + CHECK_EQUAL(slug_name{floor - 1}.to_string(), std::string{}) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_groups_shared_prefixes_in_the_high_bits) + // The grouping property slug_name exists for: a shared textual prefix is a + // shared leading BIT prefix, so prefix-related codes are contiguous in key + // order. k symbols share the top 6k bits of the 48-bit payload. + auto shared_high_bits = [](slug_name a, slug_name b) { + int n = 0; + for (int bit = 47; bit >= 0; --bit) { + if (((a.value >> bit) & 1ull) != ((b.value >> bit) & 1ull)) break; + ++n; + } + return n; + }; + CHECK_EQUAL(shared_high_bits("LIQSOL"_s, "LIQETH"_s) >= 18, true) // "LIQ" = 3 x 6 + CHECK_EQUAL(shared_high_bits("WIRE"_s, "WIREUSD"_s) >= 24, true) // "WIRE" = 4 x 6 + // And a differing first symbol shares nothing in the top 6 bits. + CHECK_EQUAL(shared_high_bits("ETH"_s, "WIRE"_s) < 6, true) +SYSIO_TEST_END + +int main(int argc, char* argv[]) { + SYSIO_TEST(slug_name_byte_identity_with_the_host) + SYSIO_TEST(slug_name_round_trips_canonical_spellings) + SYSIO_TEST(slug_name_literals_are_compile_time) + SYSIO_TEST(slug_name_canonical_values_are_at_or_above_the_2_42_floor) + SYSIO_TEST(slug_name_groups_shared_prefixes_in_the_high_bits) + return has_failed(); +} From 3cb4b43fb65cf1d085061fb4157ecc0715846d73 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 16 Sep 2026 08:08:17 -0500 Subject: [PATCH 02/12] test(sysiolib): guard the two load-bearing lines of the slug_name builtin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both changes this commit's parent made were untestable by its own suite: deleting either left all 35 unit tests and all 69 toolchain tests green. - tests/toolchain/abigen-pass/slug_name_builtin: pins that a sysio::slug_name field reaches the ABI as the bare builtin on both paths that can leak it — an action field type and a kv table's key_types — with no struct_def and no typedef. Verified by removing the gen.hpp builtins entry and rebuilding: abigen then emits `types: [slug_name -> basic_name_slug_name_traits]` plus that struct, while the field type and key_types still read "slug_name", which is why the failure is silent. With the entry removed this fixture is the only one of 70 that fails. - slug_name_to_key_writes_eight_bytes: pins CDT_REFLECT(value) on basic_name. to_key's generic arm reflects rather than consulting operator<<, so an unreflected basic_name writes a zero-byte key with no diagnostic. Every other case in the file exercises the type and passes with the reflection deleted. Note the test must include key_utils.hpp explicitly — nothing else does, which is itself why to_key had no coverage. Change-Id: I7f8eb5884a1f50e01767b00ef64cbfdfa0872d20 --- .../abigen-pass/slug_name_builtin.abi | 61 +++++++++++++++++++ .../abigen-pass/slug_name_builtin.cpp | 41 +++++++++++++ .../abigen-pass/slug_name_builtin.json | 1 + tests/unit/slug_name_tests.cpp | 28 +++++++++ 4 files changed, 131 insertions(+) create mode 100644 tests/toolchain/abigen-pass/slug_name_builtin.abi create mode 100644 tests/toolchain/abigen-pass/slug_name_builtin.cpp create mode 100644 tests/toolchain/abigen-pass/slug_name_builtin.json diff --git a/tests/toolchain/abigen-pass/slug_name_builtin.abi b/tests/toolchain/abigen-pass/slug_name_builtin.abi new file mode 100644 index 000000000..0b301cece --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_builtin.abi @@ -0,0 +1,61 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "code_key", + "base": "", + "fields": [ + { + "name": "code", + "type": "slug_name" + } + ] + }, + { + "name": "code_row", + "base": "", + "fields": [ + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "reg", + "base": "", + "fields": [ + { + "name": "code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "reg", + "type": "reg", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "codes", + "type": "code_row", + "index_type": "i64", + "key_names": ["code"], + "key_types": ["slug_name"], + "table_id": 15464 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/tests/toolchain/abigen-pass/slug_name_builtin.cpp b/tests/toolchain/abigen-pass/slug_name_builtin.cpp new file mode 100644 index 000000000..c7e39e8f7 --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_builtin.cpp @@ -0,0 +1,41 @@ +// `sysio::slug_name` must reach the ABI as the BARE builtin spelling -- no struct_def, +// no typedef -- on both paths that can leak it: an action field type, and a kv table's +// key_types. +// +// It is an ALIAS (`using slug_name = basic_name`), so without the +// `builtins` entry in gen.hpp, `is_aliasing` returns true and abigen emits +// `types: [slug_name -> basic_name_slug_name_traits_]` plus that struct. The host +// resolves typedefs BEFORE its builtin lookup, so every slug field would silently +// serialize as `{"value": N}` again -- no error anywhere. That is what this fixture +// pins: the expected ABI below must contain NO slug_name struct and NO typedef. +// +// Expected: action field `code` of type "slug_name"; table `codes` with +// key_types ["slug_name"]. +#include +#include +#include + +using namespace sysio; + +class [[sysio::contract("slug_name_builtin")]] slug_name_builtin : public contract { +public: + using contract::contract; + + struct code_key { + sysio::slug_name code; + SYSLIB_SERIALIZE(code_key, (code)) + }; + + struct [[sysio::table("codes")]] code_row { + uint64_t amount; + SYSLIB_SERIALIZE(code_row, (amount)) + }; + + using codes = kv::table<"codes"_n, code_key, code_row>; + + [[sysio::action]] + void reg(sysio::slug_name code, uint64_t amount) { + codes tbl(get_self()); + tbl.emplace(get_self(), {code}, {amount}); + } +}; diff --git a/tests/toolchain/abigen-pass/slug_name_builtin.json b/tests/toolchain/abigen-pass/slug_name_builtin.json new file mode 100644 index 000000000..2929dcf0b --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_builtin.json @@ -0,0 +1 @@ +{ "tests": [{ "expected": { "abi-file": "slug_name_builtin.abi" } }] } diff --git a/tests/unit/slug_name_tests.cpp b/tests/unit/slug_name_tests.cpp index dab6e0227..4e588a61a 100644 --- a/tests/unit/slug_name_tests.cpp +++ b/tests/unit/slug_name_tests.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -91,11 +92,38 @@ SYSIO_TEST_BEGIN(slug_name_groups_shared_prefixes_in_the_high_bits) CHECK_EQUAL(shared_high_bits("ETH"_s, "WIRE"_s) < 6, true) SYSIO_TEST_END +SYSIO_TEST_BEGIN(slug_name_to_key_writes_eight_bytes) + // Guards CDT_REFLECT(value) on basic_name. to_key's generic arm dispatches + // floating-point / integral / enum and otherwise REFLECTS (key_utils.hpp:302-325) + // -- it never consults operator<<, so SYSLIB_SERIALIZE does not help it. An + // unreflected basic_name yields field_count 0 and writes a ZERO-BYTE key, + // silently. Every other case in this file exercises the TYPE (packing, literals, + // the 2^42 floor, prefix grouping) and would still pass with the reflection + // deleted; this one would not. + // + // to_key has no callers today -- key_utils.hpp is included by nothing, and both + // kv::table and multi_index encode through kv_utils.hpp's be_key_stream -- so the + // reflection is defensive. This test is what keeps it correct for the day + // something does reach it. + sysio::slug_name code{"LIQSOL"}; + char buf[16] = {}; + sysio::datastream ds(buf, sizeof(buf)); + sysio::to_key(code, ds); + CHECK_EQUAL( ds.tellp(), 8 ) + + // Big-endian, so byte order matches value order -- the property prefix + // grouping depends on. + uint64_t be = 0; + for (int i = 0; i < 8; ++i) be = (be << 8) | static_cast(buf[i]); + CHECK_EQUAL( be, code.value ) +SYSIO_TEST_END + int main(int argc, char* argv[]) { SYSIO_TEST(slug_name_byte_identity_with_the_host) SYSIO_TEST(slug_name_round_trips_canonical_spellings) SYSIO_TEST(slug_name_literals_are_compile_time) SYSIO_TEST(slug_name_canonical_values_are_at_or_above_the_2_42_floor) SYSIO_TEST(slug_name_groups_shared_prefixes_in_the_high_bits) + SYSIO_TEST(slug_name_to_key_writes_eight_bytes) return has_failed(); } From 959f29b462615b1ab8d0668fd12944bf061574c1 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 16 Sep 2026 15:33:00 -0500 Subject: [PATCH 03/12] style(sysiolib): wrap slug_name to the repo's 120-column limit The new files were wrapped at 87-92 columns while CLAUDE.md sets 120 for new code and the neighbouring headers run past 300. Comment prose only; no declaration, constant or test assertion changed. slug_name_tests 6/6, ctest unit_tests 32/32, toolchain 70/70. Change-Id: I21097253e8b5e531c5edc89c09c4ea2b2ed6b60a --- libraries/sysiolib/core/sysio/basic_name.hpp | 9 ++- libraries/sysiolib/core/sysio/slug_name.hpp | 68 +++++++++----------- tests/unit/slug_name_tests.cpp | 55 +++++++--------- 3 files changed, 57 insertions(+), 75 deletions(-) diff --git a/libraries/sysiolib/core/sysio/basic_name.hpp b/libraries/sysiolib/core/sysio/basic_name.hpp index 18392a45a..e79ca00c1 100644 --- a/libraries/sysiolib/core/sysio/basic_name.hpp +++ b/libraries/sysiolib/core/sysio/basic_name.hpp @@ -151,11 +151,10 @@ struct basic_name { friend constexpr bool operator==( basic_name a, basic_name b ) = default; SYSLIB_SERIALIZE( basic_name, (value) ) - // Bluegrass reflection, needed by CDT's to_key: its generic dispatches on - // is_floating_point / is_integral / is_enum and otherwise reflects, never - // consulting operator<<. Without this a basic_name reaching to_key reflects - // as invalid_fields and silently encodes a ZERO-BYTE key. Declared here - // rather than per-instantiation so every traits specialisation is covered. + // Bluegrass reflection, needed by CDT's to_key: its generic dispatches on is_floating_point / + // is_integral / is_enum and otherwise reflects, never consulting operator<<. Without this a basic_name + // reaching to_key reflects as invalid_fields and silently encodes a ZERO-BYTE key. Declared here rather + // than per-instantiation so every traits specialisation is covered. CDT_REFLECT(value); private: diff --git a/libraries/sysiolib/core/sysio/slug_name.hpp b/libraries/sysiolib/core/sysio/slug_name.hpp index 9aa67f048..d00322727 100644 --- a/libraries/sysiolib/core/sysio/slug_name.hpp +++ b/libraries/sysiolib/core/sysio/slug_name.hpp @@ -14,71 +14,63 @@ namespace sysio { * @brief Packed registry-code identifier */ - /// Alphabet + length traits for the registry-code encoding: up to 8 symbols - /// over [A-Z0-9_]. Drives sysio::basic_name. + /// Alphabet + length traits for the registry-code encoding: up to 8 symbols over [A-Z0-9_]. + /// Drives sysio::basic_name. /// - /// Byte-identical with the host-side fc::slug_name, which instantiates - /// fc::basic_name over the same alphabet, length, terminator rule and - /// packing direction. The two traits structs are the whole specification — - /// keep them diffable line for line. + /// Byte-identical with the host-side fc::slug_name, which instantiates fc::basic_name over the same + /// alphabet, length, terminator rule and packing direction. The two traits structs are the whole + /// specification — keep them diffable line for line. struct slug_name_traits { - /// 8, not the 10 that would fill 64 bits: 8 symbols x 6 bits = 48, so - /// every encoded value stays in [0, 2^48) — under JS Number's 2^53 safe - /// integer limit, letting TS consumers use `number` rather than bigint. + /// 8, not the 10 that would fill 64 bits: 8 symbols x 6 bits = 48, so every encoded value stays in + /// [0, 2^48) — under JS Number's 2^53 safe integer limit, letting TS consumers use `number` rather + /// than bigint. static constexpr int max_len = 8; - /// Symbol 0 is the '\0' pad/terminator; 1-26 = A-Z, 27-36 = 0-9, 37 = '_'. - /// Held as a named array so the length comes from sizeof — a string_view - /// built straight from the literal would stop at the leading NUL. - static constexpr char alphabet_storage[] = - "\0ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; - static constexpr std::string_view alphabet{ alphabet_storage, - sizeof(alphabet_storage) - 1 }; + /// Symbol 0 is the '\0' pad/terminator; 1-26 = A-Z, 27-36 = 0-9, 37 = '_'. Held as a named array so + /// the length comes from sizeof — a string_view built straight from the literal would stop at the + /// leading NUL. + static constexpr char alphabet_storage[] = "\0ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; + static constexpr std::string_view alphabet{ alphabet_storage, sizeof(alphabet_storage) - 1 }; - /// A symbol-0 slot TERMINATES the string, unlike name's '.' which is an - /// ordinary interior character. This is why a slug_name is not total over - /// uint64: every value below 2^42 has a zero in the char[0] slot and so - /// decodes to the empty string. + /// A symbol-0 slot TERMINATES the string, unlike name's '.' which is an ordinary interior character. + /// This is why a slug_name is not total over uint64: every value below 2^42 has a zero in the char[0] + /// slot and so decodes to the empty string. static constexpr bool zero_terminates = true; - /// MSB-first: char[0] occupies bits [42..47]. This is what gives the - /// packed value its grouping property — a shared textual prefix is a - /// shared leading bit prefix, so prefix-related codes are contiguous in - /// key order and retrievable as a range. + /// MSB-first: char[0] occupies bits [42..47]. This is what gives the packed value its grouping + /// property — a shared textual prefix is a shared leading bit prefix, so prefix-related codes are + /// contiguous in key order and retrievable as a range. static constexpr basic_name_endianness packing = basic_name_endianness::MSB; static constexpr const char* bad_char_message = "character is not in allowed character set for slug_names ([A-Z0-9_])"; - static constexpr const char* too_long_message = - "string is too long to be a valid slug_name"; + static constexpr const char* too_long_message = "string is too long to be a valid slug_name"; static constexpr const char* bad_final_symbol_message = "final character in slug_name does not fit its packed slot"; }; /// Packed registry-code identifier — up to 8 symbols over [A-Z0-9_]. /// - /// Declared as an alias rather than a derived struct on purpose. abigen - /// matches builtins on the namespace-stripped written spelling, and - /// `slug_name` is in that set (plugins/sysio/gen.hpp), so no typedef and no - /// struct_def is emitted and a field declared `sysio::slug_name` carries the - /// bare ABI type name. A derived struct would reflect as a base with zero - /// declared fields the moment it stopped being a builtin. + /// Declared as an alias rather than a derived struct on purpose. abigen matches builtins on the + /// namespace-stripped written spelling, and `slug_name` is in that set (plugins/sysio/gen.hpp), so no + /// typedef and no struct_def is emitted and a field declared `sysio::slug_name` carries the bare ABI + /// type name. A derived struct would reflect as a base with zero declared fields the moment it stopped + /// being a builtin. using slug_name = basic_name; } // namespace sysio /** - * Compile-time slug_name literal: `"ETH"_s`, `"LIQSOL"_s`. Validation happens - * in basic_name's constexpr constructor — a character outside [A-Z0-9_], or - * more than 8 characters, fails the constant evaluation. Mirrors the shape of - * sysio::name's `_n` literal, including its global scope. + * Compile-time slug_name literal: `"ETH"_s`, `"LIQSOL"_s`. Validation happens in basic_name's constexpr + * constructor — a character outside [A-Z0-9_], or more than 8 characters, fails the constant evaluation. + * Mirrors the shape of sysio::name's `_n` literal, including its global scope. */ #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wgnu-string-literal-operator-template" template inline constexpr sysio::slug_name operator""_s() { - constexpr auto x = sysio::slug_name{ - std::string_view{ sysio::detail::to_const_char_arr::value, sizeof...(Str) } }; + constexpr auto x = sysio::slug_name{ std::string_view{ sysio::detail::to_const_char_arr::value, + sizeof...(Str) } }; return x; } #pragma clang diagnostic pop diff --git a/tests/unit/slug_name_tests.cpp b/tests/unit/slug_name_tests.cpp index 4e588a61a..89f540dd1 100644 --- a/tests/unit/slug_name_tests.cpp +++ b/tests/unit/slug_name_tests.cpp @@ -2,17 +2,14 @@ * @file * @copyright defined in sysio.cdt/LICENSE.txt * - * Unit tests for sysio::slug_name — the packed registry-code identifier, an - * instantiation of sysio::basic_name over the [A-Z0-9_] alphabet. + * Unit tests for sysio::slug_name — the packed registry-code identifier, an instantiation of sysio::basic_name over + * the [A-Z0-9_] alphabet. * - * basic_name's generic behaviour (both packing directions, the traits concept) - * is covered by basic_name_tests.cpp. What is pinned HERE is the slug policy - * itself, and above all its BYTE IDENTITY with the host-side fc::slug_name: - * the two are separate instantiations in separate toolchains, agreeing only - * because their traits agree. The expected values below are what fc produces. - * If either side's alphabet, length, terminator rule or packing direction - * drifts, these fail — which is the only mechanical guard the cross-language - * encoding has. + * basic_name's generic behaviour (both packing directions, the traits concept) is covered by basic_name_tests.cpp. + * What is pinned HERE is the slug policy itself, and above all its BYTE IDENTITY with the host-side fc::slug_name: + * the two are separate instantiations in separate toolchains, agreeing only because their traits agree. The expected + * values below are what fc produces. If either side's alphabet, length, terminator rule or packing direction drifts, + * these fail — which is the only mechanical guard the cross-language encoding has. */ #include @@ -36,8 +33,8 @@ static_assert(sysio::slug_name_traits::alphabet.size() == 38, "'\\0' pad + A-Z + 0-9 + '_'"); SYSIO_TEST_BEGIN(slug_name_byte_identity_with_the_host) - // These constants are fc::slug_name's output. Do not "fix" a failure by - // editing them — a mismatch means the two traits have diverged. + // These constants are fc::slug_name's output. Do not "fix" a failure by editing them — a mismatch means the two + // traits have diverged. CHECK_EQUAL(slug_name{"A"}.value, 4398046511104ull) CHECK_EQUAL(slug_name{"ETH"}.value, 23373212024832ull) CHECK_EQUAL(slug_name{"WIRE"}.value, 101792956284928ull) @@ -63,10 +60,9 @@ SYSIO_TEST_BEGIN(slug_name_literals_are_compile_time) SYSIO_TEST_END SYSIO_TEST_BEGIN(slug_name_canonical_values_are_at_or_above_the_2_42_floor) - // char[0] sits at bits [42..47], so any canonical (non-empty) slug is - // >= 1<<42 — and conversely every value below the floor has a zero in the - // char[0] slot and so decodes to the empty string. This is why the host's - // JSON carrier cannot be string-only. + // char[0] sits at bits [42..47], so any canonical (non-empty) slug is >= 1<<42 — and conversely every value below + // the floor has a zero in the char[0] slot and so decodes to the empty string. This is why the host's JSON carrier + // cannot be string-only. constexpr uint64_t floor = 1ull << 42; CHECK_EQUAL(slug_name{"A"}.value >= floor, true) CHECK_EQUAL(slug_name{"ETH"}.value >= floor, true) @@ -75,9 +71,8 @@ SYSIO_TEST_BEGIN(slug_name_canonical_values_are_at_or_above_the_2_42_floor) SYSIO_TEST_END SYSIO_TEST_BEGIN(slug_name_groups_shared_prefixes_in_the_high_bits) - // The grouping property slug_name exists for: a shared textual prefix is a - // shared leading BIT prefix, so prefix-related codes are contiguous in key - // order. k symbols share the top 6k bits of the 48-bit payload. + // The grouping property slug_name exists for: a shared textual prefix is a shared leading BIT prefix, so + // prefix-related codes are contiguous in key order. k symbols share the top 6k bits of the 48-bit payload. auto shared_high_bits = [](slug_name a, slug_name b) { int n = 0; for (int bit = 47; bit >= 0; --bit) { @@ -93,26 +88,22 @@ SYSIO_TEST_BEGIN(slug_name_groups_shared_prefixes_in_the_high_bits) SYSIO_TEST_END SYSIO_TEST_BEGIN(slug_name_to_key_writes_eight_bytes) - // Guards CDT_REFLECT(value) on basic_name. to_key's generic arm dispatches - // floating-point / integral / enum and otherwise REFLECTS (key_utils.hpp:302-325) - // -- it never consults operator<<, so SYSLIB_SERIALIZE does not help it. An - // unreflected basic_name yields field_count 0 and writes a ZERO-BYTE key, - // silently. Every other case in this file exercises the TYPE (packing, literals, - // the 2^42 floor, prefix grouping) and would still pass with the reflection - // deleted; this one would not. + // Guards CDT_REFLECT(value) on basic_name. to_key's generic arm dispatches floating-point / integral / enum and + // otherwise REFLECTS (key_utils.hpp:302-325) -- it never consults operator<<, so SYSLIB_SERIALIZE does not help + // it. An unreflected basic_name yields field_count 0 and writes a ZERO-BYTE key, silently. Every other case in + // this file exercises the TYPE (packing, literals, the 2^42 floor, prefix grouping) and would still pass with the + // reflection deleted; this one would not. // - // to_key has no callers today -- key_utils.hpp is included by nothing, and both - // kv::table and multi_index encode through kv_utils.hpp's be_key_stream -- so the - // reflection is defensive. This test is what keeps it correct for the day - // something does reach it. + // to_key has no callers today -- key_utils.hpp is included by nothing, and both kv::table and multi_index encode + // through kv_utils.hpp's be_key_stream -- so the reflection is defensive. This test is what keeps it correct for + // the day something does reach it. sysio::slug_name code{"LIQSOL"}; char buf[16] = {}; sysio::datastream ds(buf, sizeof(buf)); sysio::to_key(code, ds); CHECK_EQUAL( ds.tellp(), 8 ) - // Big-endian, so byte order matches value order -- the property prefix - // grouping depends on. + // Big-endian, so byte order matches value order -- the property prefix grouping depends on. uint64_t be = 0; for (int i = 0; i < 8; ++i) be = (be << 8) | static_cast(buf[i]); CHECK_EQUAL( be, code.value ) From af359a92f04bf21b0d3e29c7e1bbec08c47ef75e Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 17 Sep 2026 13:06:52 -0500 Subject: [PATCH 04/12] feat(sysiolib): a slug_name code must start with a letter Mirrors the host-side rule in fc::slug_name_traits: no legal code can be spelled like a number, which is what makes the host's string carrier unambiguous. Digits and '_' stay legal after the first position. leading_alphabet / bad_leading_char_message are OPTIONAL traits members, so sysio::name is unaffected. Change-Id: Ife81282e7a14f18b2c815ecf52d46631fece004b --- libraries/sysiolib/core/sysio/basic_name.hpp | 18 +++++++++++++ libraries/sysiolib/core/sysio/slug_name.hpp | 11 ++++++++ tests/unit/slug_name_tests.cpp | 27 +++++++++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/libraries/sysiolib/core/sysio/basic_name.hpp b/libraries/sysiolib/core/sysio/basic_name.hpp index e79ca00c1..505e2d5d7 100644 --- a/libraries/sysiolib/core/sysio/basic_name.hpp +++ b/libraries/sysiolib/core/sysio/basic_name.hpp @@ -68,6 +68,18 @@ concept basic_name_traits = && Traits::max_len > 0 && std::string_view{ Traits::alphabet }.size() > 0; +/// OPTIONAL traits members: the symbols a spelling may START with, and the +/// message to report when it does not. Traits that omit them accept any +/// alphabet character in the leading position, which is what `sysio::name` +/// wants. `slug_name` supplies them so that no legal code can be confused with +/// a decimal number - see `slug_name_traits::leading_alphabet`. Byte-identical +/// with the host-side fc::basic_name. +template +concept basic_name_has_leading_alphabet = requires { + { Traits::leading_alphabet } -> std::convertible_to; + { Traits::bad_leading_char_message } -> std::convertible_to; +}; + template struct basic_name { uint64_t value = 0; @@ -88,6 +100,12 @@ struct basic_name { // reaches check and is therefore a compile error). if ( str.size() > static_cast(Traits::max_len) ) sysio::check( false, Traits::too_long_message ); + if constexpr ( basic_name_has_leading_alphabet ) { + if ( !str.empty() + && std::string_view{ Traits::leading_alphabet }.find( str[0] ) + == std::string_view::npos ) + sysio::check( false, Traits::bad_leading_char_message ); + } const int n = static_cast(str.size()); for ( int i = 0; i < Traits::max_len && i < n; ++i ) { const uint64_t sym = symbol( str[i] ); diff --git a/libraries/sysiolib/core/sysio/slug_name.hpp b/libraries/sysiolib/core/sysio/slug_name.hpp index d00322727..14d5c99b5 100644 --- a/libraries/sysiolib/core/sysio/slug_name.hpp +++ b/libraries/sysiolib/core/sysio/slug_name.hpp @@ -32,6 +32,15 @@ namespace sysio { static constexpr char alphabet_storage[] = "\0ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; static constexpr std::string_view alphabet{ alphabet_storage, sizeof(alphabet_storage) - 1 }; + /// A code must START with a letter. This is what makes the host's string + /// carrier unambiguous: no legal code can be spelled like a number, so a + /// bare JSON string is always a code and never a decimal. Without it the + /// alphabet's digits make "7" both a valid code and a valid decimal, and + /// "1E3" / "0X10" additionally collide with JS numeric syntax. Digits and + /// '_' remain legal in every position after the first ("V1", "TRAIL_"). + /// The empty string is unaffected - it is the zero sentinel. + static constexpr std::string_view leading_alphabet{ "ABCDEFGHIJKLMNOPQRSTUVWXYZ" }; + /// A symbol-0 slot TERMINATES the string, unlike name's '.' which is an ordinary interior character. /// This is why a slug_name is not total over uint64: every value below 2^42 has a zero in the char[0] /// slot and so decodes to the empty string. @@ -45,6 +54,8 @@ namespace sysio { static constexpr const char* bad_char_message = "character is not in allowed character set for slug_names ([A-Z0-9_])"; static constexpr const char* too_long_message = "string is too long to be a valid slug_name"; + static constexpr const char* bad_leading_char_message = + "slug_name must start with a letter ([A-Z])"; static constexpr const char* bad_final_symbol_message = "final character in slug_name does not fit its packed slot"; }; diff --git a/tests/unit/slug_name_tests.cpp b/tests/unit/slug_name_tests.cpp index 89f540dd1..9a4d84396 100644 --- a/tests/unit/slug_name_tests.cpp +++ b/tests/unit/slug_name_tests.cpp @@ -40,18 +40,38 @@ SYSIO_TEST_BEGIN(slug_name_byte_identity_with_the_host) CHECK_EQUAL(slug_name{"WIRE"}.value, 101792956284928ull) CHECK_EQUAL(slug_name{"SOLANA"}.value, 84606581215232ull) CHECK_EQUAL(slug_name{"LIQSOL"}.value, 53413609783296ull) - CHECK_EQUAL(slug_name{"12345678"}.value, 125170908010659ull) - CHECK_EQUAL(slug_name{"_"}.value, 162727720910848ull) + CHECK_EQUAL(slug_name{"Z1234567"}.value, 116305004726370ull) + CHECK_EQUAL(slug_name{"Z_______"}.value, 116932188985701ull) SYSIO_TEST_END SYSIO_TEST_BEGIN(slug_name_round_trips_canonical_spellings) - for (const char* s : {"A", "ETH", "WIRE", "SOLANA", "LIQSOL", "12345678", "_"}) { + for (const char* s : {"A", "ETH", "WIRE", "SOLANA", "LIQSOL", "Z1234567", "Z_______"}) { CHECK_EQUAL(slug_name{std::string_view{s}}.to_string(), std::string{s}) } CHECK_EQUAL(slug_name{}.to_string(), std::string{}) CHECK_EQUAL(slug_name{std::string_view{""}}.value, 0ull) SYSIO_TEST_END +SYSIO_TEST_BEGIN(slug_name_must_start_with_a_letter) + // The rule that makes the host's string carrier unambiguous: no legal code + // can be spelled like a number. Byte-identical with the host-side rule in + // fc::slug_name_traits::leading_alphabet — keep the two diffable. + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"7"}}; }) + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"0"}}; }) + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"1E3"}}; }) + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"12345678"}}; }) + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"_"}}; }) + // Digits and '_' stay legal after the first symbol, and "" is the sentinel. + CHECK_EQUAL(slug_name{std::string_view{"V1"}}.to_string(), std::string{"V1"}) + CHECK_EQUAL(slug_name{std::string_view{"TRAIL_"}}.to_string(), std::string{"TRAIL_"}) + CHECK_EQUAL(slug_name{std::string_view{""}}.value, 0ull) +SYSIO_TEST_END + SYSIO_TEST_BEGIN(slug_name_literals_are_compile_time) static_assert("ETH"_s.value == 23373212024832ull); static_assert("LIQSOL"_s.value == 53413609783296ull); @@ -112,6 +132,7 @@ SYSIO_TEST_END int main(int argc, char* argv[]) { SYSIO_TEST(slug_name_byte_identity_with_the_host) SYSIO_TEST(slug_name_round_trips_canonical_spellings) + SYSIO_TEST(slug_name_must_start_with_a_letter) SYSIO_TEST(slug_name_literals_are_compile_time) SYSIO_TEST(slug_name_canonical_values_are_at_or_above_the_2_42_floor) SYSIO_TEST(slug_name_groups_shared_prefixes_in_the_high_bits) From e52be2ca92c81552a862647cd0ec61825db16594 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 21 Sep 2026 08:02:32 -0500 Subject: [PATCH 05/12] feat(sysiolib): share one validation algorithm with the host basic_name gains validity_error() -- the single predicate both the constructor and is_valid_literal() use -- plus pack() and is_canonical(). Token-identical with the host-side fc::basic_name; only the throw mechanism differs, so the two stay diffable. slug_name becomes a derived struct like name. An alias never reaches abigen's builtin match: abigen resolves it to the underlying template and emits a typedef plus a struct_def for the instantiation, which the host -- knowing slug_name intrinsically -- rejects as a duplicate type definition. abigen: add_struct skips a type the ABI knows intrinsically, so a builtin's base is no longer described as an orphan struct_def no field references. Change-Id: Ie00ff09437eecc0482c42e88f4018a5379426aec --- libraries/sysiolib/core/sysio/basic_name.hpp | 124 +++++++++++++++---- libraries/sysiolib/core/sysio/name.hpp | 3 + libraries/sysiolib/core/sysio/slug_name.hpp | 23 +++- plugins/sysio/abigen.hpp | 12 ++ tests/unit/basic_name_tests.cpp | 5 + tests/unit/name_tests.cpp | 55 ++++---- 6 files changed, 167 insertions(+), 55 deletions(-) diff --git a/libraries/sysiolib/core/sysio/basic_name.hpp b/libraries/sysiolib/core/sysio/basic_name.hpp index 505e2d5d7..253a808ff 100644 --- a/libraries/sysiolib/core/sysio/basic_name.hpp +++ b/libraries/sysiolib/core/sysio/basic_name.hpp @@ -64,6 +64,7 @@ concept basic_name_traits = { Traits::bad_char_message } -> std::convertible_to; { Traits::too_long_message } -> std::convertible_to; { Traits::bad_final_symbol_message } -> std::convertible_to; + { Traits::not_normalized_message } -> std::convertible_to; } && Traits::max_len > 0 && std::string_view{ Traits::alphabet }.size() > 0; @@ -87,39 +88,83 @@ struct basic_name { constexpr basic_name() = default; constexpr explicit basic_name( uint64_t v ) : value(v) {} - /// Per-character validated string constructor. sysio::check-throws on an - /// over-long string, an out-of-alphabet character, a final symbol too - /// wide for its (possibly narrowed) slot, or - for zero_terminates traits - /// only - the pad symbol embedded anywhere in the string (such a literal - /// would silently decode to just its prefix; rejecting it keeps the - /// literal and the canonical decoding in agreement). constexpr - so an - /// invalid `_n` / `_s` literal is a compile error. + /// Construct from a string. sysio::check-throws unless the input is the + /// canonical spelling of its own encoding - see validity_error(), the + /// SINGLE validation algorithm this type has, shared with + /// is_valid_literal() and byte-for-byte the same rules, in the same order, + /// as the host-side fc::basic_name. The two are meant to be diffable. + /// + /// constexpr - sysio::check is not, so it is reached only on the failure + /// path: a valid `_n` / `_s` literal constant-evaluates, and an invalid one + /// is a compile error rather than a silent mis-encoding. constexpr explicit basic_name( std::string_view str ) : value(0) { - // sysio::check is not constexpr - invoke it only on the failure path so - // a valid `_n` / `_s` literal still constant-evaluates (a bad one - // reaches check and is therefore a compile error). + if ( const char* why = validity_error(str) ) + sysio::check( false, why ); + value = pack(str); + } + + /// Non-validating encode - the constexpr path used by literals. Characters + /// outside the alphabet pack as symbol 0. + static constexpr uint64_t pack( std::string_view str ) { + uint64_t v = 0; + const int n = static_cast(str.size()); + for ( int i = 0; i < Traits::max_len && i < n; ++i ) + v |= (sym_of(str[i]) & width_mask(i)) << shift(i); + return v; + } + + /// Is `str` a valid, canonical spelling? Delegates to validity_error so the + /// literal path and the throwing constructor can never disagree. + static constexpr bool is_valid_literal( std::string_view str ) { + return validity_error(str) == nullptr; + } + + /// THE validation algorithm. Returns nullptr when `str` is a valid, + /// canonical spelling; otherwise the traits' message for the FIRST rule it + /// breaks. Rules 4-6 make pack() lossless, so to_string(pack(str)) IS str. + /// Identical to fc::basic_name::validity_error - keep the two in lock-step. + static constexpr const char* validity_error( std::string_view str ) { + // 1. length if ( str.size() > static_cast(Traits::max_len) ) - sysio::check( false, Traits::too_long_message ); + return Traits::too_long_message; + + // 2. leading symbol, for traits that restrict it if constexpr ( basic_name_has_leading_alphabet ) { if ( !str.empty() && std::string_view{ Traits::leading_alphabet }.find( str[0] ) == std::string_view::npos ) - sysio::check( false, Traits::bad_leading_char_message ); + return Traits::bad_leading_char_message; } - const int n = static_cast(str.size()); - for ( int i = 0; i < Traits::max_len && i < n; ++i ) { - const uint64_t sym = symbol( str[i] ); + + for ( std::size_t i = 0; i < str.size(); ++i ) { + const std::size_t sym = Traits::alphabet.find( str[i] ); + + // 3. in the alphabet + if ( sym == std::string_view::npos ) + return Traits::bad_char_message; + + // 4. a zero-terminated alphabet has no INTERIOR pad: to_string() stops + // at the first symbol-0 slot, so such a spelling cannot round-trip. if constexpr ( Traits::zero_terminates ) { - // sym == 0 is the pad/terminator slot. For zero_terminates traits, - // an interior pad would make to_string() truncate (e.g. "A\0B" - // decodes to "A"), so the input would not round-trip. Reject. if ( sym == 0 ) - sysio::check( false, Traits::bad_char_message ); + return Traits::bad_char_message; } - if ( sym > width_mask(i) ) - sysio::check( false, Traits::bad_final_symbol_message ); - value |= sym << shift(i); + + // 5. the final slot may be narrower than `bits` (13 x 5 > 64 for name, + // leaving 4 bits), and pack() would silently truncate a symbol too + // wide for it. + if ( static_cast(sym) > width_mask( static_cast(i) ) ) + return Traits::bad_final_symbol_message; + } + + // 6. a non-zero-terminated alphabet strips TRAILING pads in to_string(), + // so a trailing pad cannot round-trip either. + if constexpr ( !Traits::zero_terminates ) { + if ( !str.empty() && str.back() == Traits::alphabet[0] ) + return Traits::not_normalized_message; } + + return nullptr; } constexpr uint64_t to_uint64_t() const { return value; } @@ -127,6 +172,18 @@ struct basic_name { constexpr bool good() const { return value != 0; } constexpr explicit operator bool() const { return value != 0; } + /// Does this value have a canonical spelling? A basic_name built from a RAW + /// uint64 bypasses the validating constructor, so it can hold a value no + /// spelling produces — for zero_terminates traits, anything whose leading + /// symbol slot is empty. Such a value cannot round-trip: to_string() yields a + /// text that packs to something else. Persisting one makes every later render + /// of that row throw, so writers that accept a raw uint64 off the wire gate on + /// this before storing it. + bool is_canonical() const { + const std::string text = to_string(); + return is_valid_literal(text) && pack(text) == value; + } + std::string to_string() const { std::string s; for ( int i = 0; i < Traits::max_len; ++i ) { @@ -135,10 +192,10 @@ struct basic_name { // slot; for name, symbol 0 ('.') is an ordinary interior character. if ( Traits::zero_terminates && sym == 0 ) break; - s.push_back( character( sym ) ); + s.push_back( char_of( sym ) ); } if ( !Traits::zero_terminates ) { - const char pad = character(0); + const char pad = char_of(0); while ( !s.empty() && s.back() == pad ) s.pop_back(); } @@ -188,6 +245,25 @@ struct basic_name { static_assert( (Traits::max_len - 1) * bits < 64, "basic_name: symbol layout does not fit in 64 bits" ); + /// symbol -> character; out-of-range symbols decode as the pad (alphabet[0]). + /// The private counterpart to sym_of, matching the host-side fc::basic_name. + /// symbol()/character() below stay as this type's PUBLIC surface, which + /// sysio::name re-exposes as char_to_value. + static constexpr char char_of( uint64_t s ) { + const std::string_view a = Traits::alphabet; + return s < a.size() ? a[s] : a[0]; + } + + /// character -> symbol, NON-throwing: any character outside the alphabet + /// maps to 0. Used by pack(), which is non-validating by contract; callers + /// that need rejection go through validity_error(). Mirrors fc's sym_of. + static constexpr uint64_t sym_of( char c ) { + const std::string_view a = Traits::alphabet; + for ( std::size_t s = 0; s < a.size(); ++s ) + if ( a[s] == c ) return static_cast(s); + return 0; + } + // --- Bit layout. Direction is set by Traits::packing. The final symbol // absorbs any shortfall when max_len * bits > 64. --- /// Bit offset of symbol i. MSB: symbol 0 occupies the highest bits and the diff --git a/libraries/sysiolib/core/sysio/name.hpp b/libraries/sysiolib/core/sysio/name.hpp index 25ebb55b7..890150feb 100644 --- a/libraries/sysiolib/core/sysio/name.hpp +++ b/libraries/sysiolib/core/sysio/name.hpp @@ -41,6 +41,9 @@ namespace sysio { "string is too long to be a valid name"; static constexpr const char* bad_final_symbol_message = "thirteenth character in name cannot be a letter that comes after j"; + // A legal character in an illegal position (a trailing pad) leaves a + // spelling that does not round-trip through to_string(). Host wording. + static constexpr const char* not_normalized_message = "name is not properly normalized"; }; /** diff --git a/libraries/sysiolib/core/sysio/slug_name.hpp b/libraries/sysiolib/core/sysio/slug_name.hpp index 14d5c99b5..2bbfee965 100644 --- a/libraries/sysiolib/core/sysio/slug_name.hpp +++ b/libraries/sysiolib/core/sysio/slug_name.hpp @@ -58,16 +58,27 @@ namespace sysio { "slug_name must start with a letter ([A-Z])"; static constexpr const char* bad_final_symbol_message = "final character in slug_name does not fit its packed slot"; + static constexpr const char* not_normalized_message = + "slug_name is not properly normalized"; }; /// Packed registry-code identifier — up to 8 symbols over [A-Z0-9_]. /// - /// Declared as an alias rather than a derived struct on purpose. abigen matches builtins on the - /// namespace-stripped written spelling, and `slug_name` is in that set (plugins/sysio/gen.hpp), so no - /// typedef and no struct_def is emitted and a field declared `sysio::slug_name` carries the bare ABI - /// type name. A derived struct would reflect as a base with zero declared fields the moment it stopped - /// being a builtin. - using slug_name = basic_name; + /// A DERIVED STRUCT, not an alias -- the same shape as sysio::name, and for the same reason. + /// + /// abigen matches builtins on the namespace-stripped written spelling, and `slug_name` is in that + /// set (plugins/sysio/gen.hpp) -- but an ALIAS never reaches that match: abigen resolves it through + /// to the underlying template first and then emits BOTH a typedef + /// (`slug_name` -> `basic_name_slug_name_traits`) AND a struct_def for the instantiation. The host, + /// which knows `slug_name` intrinsically, then rejects the ABI outright: + /// duplicate_abi_type_def_exception: type already exists 'slug_name' + /// `sysio::name` avoids this only because it is a derived struct, so the builtin match applies to + /// the written name. slug_name follows it. + struct slug_name : basic_name { + using base = basic_name; + using base::base; // slug_name(uint64_t), slug_name(std::string_view) + constexpr slug_name() = default; + }; } // namespace sysio diff --git a/plugins/sysio/abigen.hpp b/plugins/sysio/abigen.hpp index f223f7fc9..d5b019eab 100644 --- a/plugins/sysio/abigen.hpp +++ b/plugins/sysio/abigen.hpp @@ -287,6 +287,18 @@ namespace sysio { namespace cdt { } void add_struct( const clang::CXXRecordDecl* decl, const std::string& rname="" ) { + // A type the ABI knows INTRINSICALLY is never described -- and neither is + // its base. `struct slug_name : basic_name` (the same + // shape as `name`) would otherwise leak an orphan + // `basic_name_slug_name_traits` struct_def that no field references, + // because the base walk below runs unconditionally, before any builtin + // check applies to the derived type. add_type() already skips builtins, so + // this is only reachable where a caller force-adds a struct: the kv-key + // path adds a table's key struct so clients can reference it, which is + // right for a composite key and wrong for one that is already a builtin. + const std::string emitted_name = rname.empty() ? decl->getName().str() : rname; + if ( is_builtin_type(emitted_name) ) + return; abi_struct ret; if ( decl->getNumBases() == 1 ) { ret.base = get_type(decl->bases_begin()->getType()); diff --git a/tests/unit/basic_name_tests.cpp b/tests/unit/basic_name_tests.cpp index 0b713a1b8..1b0100d7e 100644 --- a/tests/unit/basic_name_tests.cpp +++ b/tests/unit/basic_name_tests.cpp @@ -39,6 +39,8 @@ struct test_slug_traits { "slug: string is longer than 8 characters"; static constexpr const char* bad_final_symbol_message = "slug: final symbol does not fit its slot"; + static constexpr const char* not_normalized_message = + "slug: spelling is not properly normalized"; }; using test_slug = basic_name; @@ -59,6 +61,8 @@ struct test_slug_lsb_traits { "slug-lsb: string is longer than 8 characters"; static constexpr const char* bad_final_symbol_message = "slug-lsb: final symbol does not fit its slot"; + static constexpr const char* not_normalized_message = + "slug-lsb: spelling is not properly normalized"; }; using test_slug_lsb = basic_name; @@ -70,6 +74,7 @@ struct incomplete_traits { static constexpr const char* bad_char_message = "x"; static constexpr const char* too_long_message = "x"; static constexpr const char* bad_final_symbol_message = "x"; + static constexpr const char* not_normalized_message = "x"; }; } // namespace diff --git a/tests/unit/name_tests.cpp b/tests/unit/name_tests.cpp index 0e4a9a2d7..5b9266195 100644 --- a/tests/unit/name_tests.cpp +++ b/tests/unit/name_tests.cpp @@ -49,9 +49,9 @@ SYSIO_TEST_BEGIN(name_type_test_ctr_str_lit) CHECK_EQUAL( name{".abc"}.value, 112167778219196416ULL ) CHECK_EQUAL( name{".........abc"}.value, 102016ULL ) - CHECK_EQUAL( name{"123."}.value, 614178399182651392ULL ) - CHECK_EQUAL( name{"123........."}.value, 614178399182651392ULL ) - CHECK_EQUAL( name{".a.b.c.1.2.3."}.value, 108209673814966320ULL ) + CHECK_EQUAL( name{name::pack("123.")}.value, 614178399182651392ULL ) + CHECK_EQUAL( name{name::pack("123.........")}.value, 614178399182651392ULL ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")}.value, 108209673814966320ULL ) CHECK_EQUAL( name{"abc.123"}.value, 3589369488740450304ULL ) CHECK_EQUAL( name{"123.abc"}.value, 614181822271586304ULL ) @@ -167,7 +167,7 @@ SYSIO_TEST_BEGIN(name_type_test_prefix) CHECK_EQUAL( name{"sysioacco.unj"}.prefix(), name{"sysioacco"} ) CHECK_EQUAL( name{"sysioaccou.nj"}.prefix(), name{"sysioaccou"} ) CHECK_EQUAL( name{"sysioaccoun.j"}.prefix(), name{"sysioaccoun"} ) - CHECK_EQUAL( name{"sysioaccounj."}.prefix(), name{"sysioaccounj"} ) + CHECK_EQUAL( name{name::pack("sysioaccounj.")}.prefix(), name{"sysioaccounj"} ) CHECK_EQUAL( name{"sysioaccountj"}.prefix(), name{"sysioaccountj"} ) CHECK_EQUAL( name{"e.o.s.i.o.a.c"}.prefix(), name{"e.o.s.i.o.a"} ) @@ -192,9 +192,9 @@ SYSIO_TEST_BEGIN(name_type_test_raw) CHECK_EQUAL( name{".abc"}.operator name::raw(), static_cast(112167778219196416ULL) ) CHECK_EQUAL( name{".........abc"}.operator name::raw(), static_cast(102016ULL) ) - CHECK_EQUAL( name{"123."}.operator name::raw(), static_cast(614178399182651392ULL) ) - CHECK_EQUAL( name{"123........."}.operator name::raw(), static_cast(614178399182651392ULL) ) - CHECK_EQUAL( name{".a.b.c.1.2.3."}.operator name::raw(), static_cast(108209673814966320ULL) ) + CHECK_EQUAL( name{name::pack("123.")}.operator name::raw(), static_cast(614178399182651392ULL) ) + CHECK_EQUAL( name{name::pack("123.........")}.operator name::raw(), static_cast(614178399182651392ULL) ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")}.operator name::raw(), static_cast(108209673814966320ULL) ) CHECK_EQUAL( name{"abc.123"}.operator name::raw(), static_cast(3589369488740450304ULL) ) CHECK_EQUAL( name{"123.abc"}.operator name::raw(), static_cast(614181822271586304ULL) ) @@ -255,11 +255,11 @@ SYSIO_TEST_BEGIN(name_type_test_memcmp) CHECK_EQUAL( memcmp(str.c_str(), buffer, strlen(str.c_str())), 0 ) name{str = ".........abc"}.write_as_string( buffer, buffer + sizeof(buffer) ); CHECK_EQUAL( memcmp(str.c_str(), buffer, strlen(str.c_str())), 0 ) - name{str = "123."}.write_as_string( buffer, buffer + sizeof(buffer) ); + name{name::pack(str = "123.")}.write_as_string( buffer, buffer + sizeof(buffer) ); CHECK_EQUAL( memcmp("123", buffer, 3), 0 ) - name{str = "123........."}.write_as_string( buffer, buffer + sizeof(buffer) ); + name{name::pack(str = "123.........")}.write_as_string( buffer, buffer + sizeof(buffer) ); CHECK_EQUAL( memcmp("123", buffer, 3), 0 ) - name{str = ".a.b.c.1.2.3."}.write_as_string( buffer, buffer + sizeof(buffer) ); + name{name::pack(str = ".a.b.c.1.2.3.")}.write_as_string( buffer, buffer + sizeof(buffer) ); CHECK_EQUAL( memcmp(".a.b.c.1.2.3", buffer, 12), 0 ) name{str = "abc.123"}.write_as_string( buffer, buffer + sizeof(buffer) ); @@ -298,9 +298,9 @@ SYSIO_TEST_BEGIN(name_type_test_to_str) CHECK_EQUAL( name{".abc"}.to_string(), ".abc" ) CHECK_EQUAL( name{".........abc"}.to_string(), ".........abc" ) - CHECK_EQUAL( name{"123."}.to_string(), "123" ) - CHECK_EQUAL( name{"123........."}.to_string(), "123" ) - CHECK_EQUAL( name{".a.b.c.1.2.3."}.to_string(), ".a.b.c.1.2.3" ) + CHECK_EQUAL( name{name::pack("123.")}.to_string(), "123" ) + CHECK_EQUAL( name{name::pack("123.........")}.to_string(), "123" ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")}.to_string(), ".a.b.c.1.2.3" ) CHECK_EQUAL( name{"abc.123"}.to_string(), "abc.123" ) CHECK_EQUAL( name{"123.abc"}.to_string(), "123.abc" ) @@ -329,9 +329,9 @@ SYSIO_TEST_BEGIN(name_type_test_equal) CHECK_EQUAL( name{".abc"} == name{".abc"}, true ) CHECK_EQUAL( name{".........abc"} == name{".........abc"}, true ) - CHECK_EQUAL( name{"123."} == name{"123"}, true ) - CHECK_EQUAL( name{"123........."} == name{"123"}, true ) - CHECK_EQUAL( name{".a.b.c.1.2.3."} == name{".a.b.c.1.2.3"}, true ) + CHECK_EQUAL( name{name::pack("123.")} == name{"123"}, true ) + CHECK_EQUAL( name{name::pack("123.........")} == name{"123"}, true ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")} == name{".a.b.c.1.2.3"}, true ) CHECK_EQUAL( name{"abc.123"} == name{"abc.123"}, true ) CHECK_EQUAL( name{"123.abc"} == name{"123.abc"}, true ) @@ -362,9 +362,9 @@ SYSIO_TEST_BEGIN(name_type_test_not_equal) CHECK_EQUAL( name{".abc"} != name{}, true ) CHECK_EQUAL( name{".........abc"} != name{}, true ) - CHECK_EQUAL( name{"123."} != name{}, true ) - CHECK_EQUAL( name{"123........."} != name{}, true ) - CHECK_EQUAL( name{".a.b.c.1.2.3."} != name{}, true ) + CHECK_EQUAL( name{name::pack("123.")} != name{}, true ) + CHECK_EQUAL( name{name::pack("123.........")} != name{}, true ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")} != name{}, true ) CHECK_EQUAL( name{"abc.123"} != name{}, true ) CHECK_EQUAL( name{"123.abc"} != name{}, true ) @@ -395,9 +395,9 @@ SYSIO_TEST_BEGIN(name_type_test_less_than) CHECK_EQUAL( name{} < name{".abc"}, true ) CHECK_EQUAL( name{} < name{".........abc"}, true ) - CHECK_EQUAL( name{} < name{"123."}, true ) - CHECK_EQUAL( name{} < name{"123........."}, true ) - CHECK_EQUAL( name{} < name{".a.b.c.1.2.3."}, true ) + CHECK_EQUAL( name{} < name{name::pack("123.")}, true ) + CHECK_EQUAL( name{} < name{name::pack("123.........")}, true ) + CHECK_EQUAL( name{} < name{name::pack(".a.b.c.1.2.3.")}, true ) CHECK_EQUAL( name{} < name{"abc.123"}, true ) CHECK_EQUAL( name{} < name{"123.abc"}, true ) @@ -431,9 +431,14 @@ SYSIO_TEST_BEGIN(name_type_test_op_n) CHECK_EQUAL( name{".abc"}, ".abc"_n ) CHECK_EQUAL( name{".........abc"}, ".........abc"_n ) - CHECK_EQUAL( name{"123."}, "123."_n ) - CHECK_EQUAL( name{"123........."}, "123........."_n ) - CHECK_EQUAL( name{".a.b.c.1.2.3."}, ".a.b.c.1.2.3."_n ) + // A TRAILING pad is a legal character in an illegal position: to_string() + // strips it, so the spelling does not round-trip and both the literal and the + // runtime constructor now reject it. Interior pads stay legal (".abc" above). + CHECK_ASSERT( "name is not properly normalized", []() { name{"123."}; } ) + CHECK_ASSERT( "name is not properly normalized", []() { name{"123........."}; } ) + CHECK_ASSERT( "name is not properly normalized", []() { name{".a.b.c.1.2.3."}; } ) + // ...but they still PACK the way they always did - the encoding is unchanged. + CHECK_EQUAL( name{name::pack("123.")}, "123"_n ) CHECK_EQUAL( name{"abc.123"}, "abc.123"_n ) CHECK_EQUAL( name{"123.abc"}, "123.abc"_n ) From fc8aca496a5f1069902ed521c93266beef37e2ba Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 21 Sep 2026 16:45:25 -0500 Subject: [PATCH 06/12] fix(sysiolib): give the derived slug_name its own datastream serializer A derived basic_name needs an EXACT-match operator<>. The base's hidden friend takes `const basic_name&`, so reaching it from slug_name needs a derived-to-base conversion and loses to the generic class overload -- which hands the type to bluegrass's field iterator and is rejected under GCC ("Types with user specified constructors are not supported"). Clang tolerates it, so CI masked it; add_native_contract() builds with the HOST compiler, so a GCC-native contract taking a slug_name action parameter would not compile. abigen gains the two fixes the same builtin spelling exposed: a struct whose name collides with a builtin is now a diagnostic instead of a silent drop that leaves the action unusable, and a table keyed DIRECTLY on a builtin publishes that one leaf instead of an empty key shape no host can decode. Change-Id: I94a2202c61b631fca227ca2fd2c7a6adf255ea49 --- libraries/sysiolib/core/sysio/name.hpp | 6 ++- libraries/sysiolib/core/sysio/slug_name.hpp | 11 ++++ plugins/sysio/abigen.hpp | 41 +++++++++++++-- plugins/sysio/gen.hpp | 7 +++ .../builtin_type_name_collision.cpp | 31 +++++++++++ .../builtin_type_name_collision.json | 10 ++++ .../abigen-pass/slug_name_builtin.cpp | 13 ++--- .../abigen-pass/slug_name_direct_kv_key.abi | 51 +++++++++++++++++++ .../abigen-pass/slug_name_direct_kv_key.cpp | 40 +++++++++++++++ .../abigen-pass/slug_name_direct_kv_key.json | 1 + tests/unit/slug_name_tests.cpp | 28 ++++++++++ 11 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision.cpp create mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision.json create mode 100644 tests/toolchain/abigen-pass/slug_name_direct_kv_key.abi create mode 100644 tests/toolchain/abigen-pass/slug_name_direct_kv_key.cpp create mode 100644 tests/toolchain/abigen-pass/slug_name_direct_kv_key.json diff --git a/libraries/sysiolib/core/sysio/name.hpp b/libraries/sysiolib/core/sysio/name.hpp index 890150feb..081b0e7f3 100644 --- a/libraries/sysiolib/core/sysio/name.hpp +++ b/libraries/sysiolib/core/sysio/name.hpp @@ -216,8 +216,10 @@ namespace sysio { CDT_REFLECT(value); // name's own serialization: an exact-match operator<<(ds, const name&) // must exist, else the generic bluegrass::meta field-iterator is chosen - // and rejects name as a non-aggregate. (basic_name has its own, used by - // slug_name, which is the alias type itself rather than a derived type.) + // and rejects name as a non-aggregate. The base's hidden friend does not + // serve a derived type -- reaching it needs a conversion, which loses to + // the exact-matching generic overload. Every derived basic_name needs its + // own; slug_name carries SYSLIB_SERIALIZE_DERIVED_EMPTY for this reason. SYSLIB_SERIALIZE( name, (value) ) }; diff --git a/libraries/sysiolib/core/sysio/slug_name.hpp b/libraries/sysiolib/core/sysio/slug_name.hpp index 2bbfee965..af7cceb4e 100644 --- a/libraries/sysiolib/core/sysio/slug_name.hpp +++ b/libraries/sysiolib/core/sysio/slug_name.hpp @@ -78,6 +78,17 @@ namespace sysio { using base = basic_name; using base::base; // slug_name(uint64_t), slug_name(std::string_view) constexpr slug_name() = default; + + /// slug_name's own serialization, for the same reason sysio::name carries one: a DERIVED type + /// needs an EXACT-match operator on itself. The base's hidden friend takes `const basic_name&`, + /// so reaching it from a `slug_name` requires a derived-to-base conversion -- and the generic + /// class-template overload, which matches exactly, wins instead. That overload hands the type to + /// the bluegrass::meta field iterator, which rejects anything with a user-declared constructor: + /// "Types with user specified constructors are not supported" + /// Clang happens to tolerate it; GCC 13 does not, which matters because add_native_contract() + /// builds with the HOST compiler and its generated dispatcher deserializes action arguments + /// through this path. Forwarding to the base keeps the bytes identical to basic_name's. + SYSLIB_SERIALIZE_DERIVED_EMPTY( slug_name, base ) }; } // namespace sysio diff --git a/plugins/sysio/abigen.hpp b/plugins/sysio/abigen.hpp index d5b019eab..d85c951b3 100644 --- a/plugins/sysio/abigen.hpp +++ b/plugins/sysio/abigen.hpp @@ -297,8 +297,21 @@ namespace sysio { namespace cdt { // path adds a table's key struct so clients can reference it, which is // right for a composite key and wrong for one that is already a builtin. const std::string emitted_name = rname.empty() ? decl->getName().str() : rname; - if ( is_builtin_type(emitted_name) ) + if ( is_builtin_type(emitted_name) ) { + // CDT's OWN builtin type -- suppress it and its base, per above. + if ( decl->getQualifiedNameAsString() == builtin_namespace_prefix + emitted_name ) + return; + // A DIFFERENT type whose emitted name collides with a builtin. Describing it makes + // the host reject the ABI (duplicate_abi_type_def_exception); dropping it silently + // -- which `validate_struct` would otherwise do -- leaves an ABI that names the + // type while the host resolves the BUILTIN's shape for it, so a client packs one + // layout and the contract unpacks another. Neither is recoverable at run time, so + // refuse here, where the author can still rename. + CDT_CHECK_ERROR(false, "abigen_error", decl->getLocation(), + "'" + emitted_name + "' collides with the built-in ABI type of the same name; " + "rename this type"); return; + } abi_struct ret; if ( decl->getNumBases() == 1 ) { ret.base = get_type(decl->bases_begin()->getType()); @@ -344,6 +357,14 @@ namespace sysio { namespace cdt { } abi_struct new_struct; new_struct.name = decl->getNameAsString(); + // The wrapper is named after the METHOD, so an action method named after a builtin + // produces a struct the ABI cannot carry: `validate_struct` drops it, leaving an action + // whose `type` names the builtin. The host then resolves the builtin's shape -- one + // 8-byte slug for `slug_name` -- while the generated dispatcher still deserializes the + // real parameter list, so the action is silently unusable. Refuse at compile time. + CDT_CHECK_ERROR(!is_builtin_type(new_struct.name), "abigen_error", decl->getLocation(), + "action method '" + new_struct.name + "' collides with the built-in ABI type of the " + "same name; rename the method (the [[sysio::action(\"...\")]] name may stay)"); for (auto param : decl->parameters() ) { auto param_type = param->getType().getNonReferenceType().getUnqualifiedType(); new_struct.fields.push_back({param->getNameAsString(), get_type(param_type)}); @@ -546,9 +567,21 @@ namespace sysio { namespace cdt { t.key_names.push_back("scope"); t.key_types.push_back("name"); } - for (auto* field : key_source->fields()) { - t.key_names.push_back(field->getName().str()); - t.key_types.push_back(translate_type(field->getType())); + if (key_source->field_empty() && is_builtin_type(key_source->getNameAsString())) { + // A key that IS a builtin -- `kv::table<"codes"_n, sysio::slug_name, row>` -- is a + // single packed scalar, not a composite, so there are no fields to walk and the loop + // below would leave key_names/key_types EMPTY. A host cannot decode a next_key or + // encode a bound from an empty shape, and nothing downstream can recover what the + // key was. Publish the one leaf it actually is, in the same single-leaf shape + // kv::global already emits (name/name). + const std::string leaf = key_source->getNameAsString(); + t.key_names.push_back(leaf); + t.key_types.push_back(leaf); + } else { + for (auto* field : key_source->fields()) { + t.key_names.push_back(field->getName().str()); + t.key_types.push_back(translate_type(field->getType())); + } } t.secondary_indexes = std::move(sec_indexes); kv_key_structs.insert(key_decl->getNameAsString()); diff --git a/plugins/sysio/gen.hpp b/plugins/sysio/gen.hpp index 61a8a090b..25e285ac3 100644 --- a/plugins/sysio/gen.hpp +++ b/plugins/sysio/gen.hpp @@ -827,6 +827,13 @@ struct generation_utils { } // --- Builtin / reserved checks --- + + /// Namespace every builtin that is a real CDT type (`name`, `slug_name`, `asset`, ...) is + /// declared in. A record whose qualified name is this prefix + a builtin spelling IS that + /// builtin; one that merely shares the unqualified spelling is a contract author's own type + /// colliding with it, which abigen must diagnose rather than silently drop. + static constexpr auto builtin_namespace_prefix = "sysio::"; + inline bool is_builtin_type( const std::string& t ) { static const std::set builtins = { diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp b/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp new file mode 100644 index 000000000..36f8eb798 --- /dev/null +++ b/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp @@ -0,0 +1,31 @@ +// An action method named after a builtin ABI type must be a DIAGNOSTIC, not a silent ABI. +// +// The wrapper struct abigen synthesizes for an action is named after the METHOD. Once +// `slug_name` joined the builtin set (wire-cdt #119), a method named `slug_name` produced a +// wrapper whose name is a builtin -- and `validate_struct` drops any such struct. The ABI then +// carried the action with `type: "slug_name"` and `structs: []`, so the host resolved the +// BUILTIN's shape (one 8-byte slug) while the generated dispatcher still deserialized the real +// parameter list (uint64 + uint32, 12 bytes). Nothing failed at build time and nothing failed at +// deploy time; the action was simply unusable, and no error pointed at why. +// +// Emitting the wrapper instead is not an option either -- the host rejects an ABI that defines a +// type it already knows intrinsically (`duplicate_abi_type_def_exception`). Since neither +// outcome is recoverable at run time, abigen refuses at compile time, where renaming the method +// costs nothing. The [[sysio::action("...")]] NAME is unaffected; only the C++ method spelling +// has to move. +#include +#include + +using namespace sysio; + +class [[sysio::contract("builtin_type_name_collision")]] builtin_type_name_collision + : public contract { +public: + using contract::contract; + + [[sysio::action("regslug")]] + void slug_name(uint64_t id, uint32_t flags) { + check(flags != 0, "flags must be set"); + print(id); + } +}; diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision.json b/tests/toolchain/abigen-fail/builtin_type_name_collision.json new file mode 100644 index 000000000..9f58ac939 --- /dev/null +++ b/tests/toolchain/abigen-fail/builtin_type_name_collision.json @@ -0,0 +1,10 @@ +{ + "tests": [ + { + "compile_flags": ["--abigen"], + "expected": { + "stderr": "collides with the built-in ABI type of the same name" + } + } + ] +} diff --git a/tests/toolchain/abigen-pass/slug_name_builtin.cpp b/tests/toolchain/abigen-pass/slug_name_builtin.cpp index c7e39e8f7..8e138c3c1 100644 --- a/tests/toolchain/abigen-pass/slug_name_builtin.cpp +++ b/tests/toolchain/abigen-pass/slug_name_builtin.cpp @@ -2,12 +2,13 @@ // no typedef -- on both paths that can leak it: an action field type, and a kv table's // key_types. // -// It is an ALIAS (`using slug_name = basic_name`), so without the -// `builtins` entry in gen.hpp, `is_aliasing` returns true and abigen emits -// `types: [slug_name -> basic_name_slug_name_traits_]` plus that struct. The host -// resolves typedefs BEFORE its builtin lookup, so every slug field would silently -// serialize as `{"value": N}` again -- no error anywhere. That is what this fixture -// pins: the expected ABI below must contain NO slug_name struct and NO typedef. +// It is a DERIVED STRUCT (`struct slug_name : basic_name`), the same shape +// as `sysio::name`, precisely so the builtin match applies to the written spelling. As an +// ALIAS it would not: `is_aliasing` returns true and abigen emits +// `types: [slug_name -> basic_name_slug_name_traits_]` plus that struct, and the host resolves +// typedefs BEFORE its builtin lookup, so every slug field would silently serialize as +// `{"value": N}` again -- no error anywhere. That is what this fixture pins: the expected ABI +// below must contain NO slug_name struct and NO typedef. // // Expected: action field `code` of type "slug_name"; table `codes` with // key_types ["slug_name"]. diff --git a/tests/toolchain/abigen-pass/slug_name_direct_kv_key.abi b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.abi new file mode 100644 index 000000000..c98b048dd --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.abi @@ -0,0 +1,51 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "code_row", + "base": "", + "fields": [ + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "reg", + "base": "", + "fields": [ + { + "name": "code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "reg", + "type": "reg", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "codes", + "type": "code_row", + "index_type": "i64", + "key_names": ["slug_name"], + "key_types": ["slug_name"], + "table_id": 15464 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/tests/toolchain/abigen-pass/slug_name_direct_kv_key.cpp b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.cpp new file mode 100644 index 000000000..98bf76362 --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.cpp @@ -0,0 +1,40 @@ +// A kv::table keyed DIRECTLY on a builtin must publish that builtin as its one key leaf. +// +// `slug_name_builtin` covers the WRAPPED form -- a `code_key` struct holding one slug -- which is +// the shape wire-sysio's registries use. That fixture never exercised the direct form, and the +// direct form was broken: key_names/key_types are derived by walking the key type's FIELDS, and a +// builtin key is a single packed scalar with none, so the ABI carried `key_names: []` and +// `key_types: []`. A host cannot decode a next_key or encode a bound from an empty shape, and +// nothing downstream can recover what the key was -- wire-sysio #619's slug key codec included. +// +// Distinct from `abigen-fail/kv_table_scalar_key`: that case keys on `uint64_t`, a NON-CLASS type +// with no CXXRecordDecl at all, and is still refused. `sysio::slug_name` is a class (a derived +// struct, the same shape as `sysio::name`), so it reaches the field walk legitimately and is +// described as the single leaf it is -- matching the one-leaf shape `kv::global` already emits. +// +// Expected: table `codes` with key_names ["slug_name"] and key_types ["slug_name"], and NO +// slug_name struct_def or typedef anywhere in the ABI. +#include +#include +#include + +using namespace sysio; + +class [[sysio::contract("slug_name_direct_kv_key")]] slug_name_direct_kv_key : public contract { +public: + using contract::contract; + + struct [[sysio::table("codes")]] code_row { + uint64_t amount; + SYSLIB_SERIALIZE(code_row, (amount)) + }; + + /// Keyed on the builtin itself, with no wrapper struct. + using codes = kv::table<"codes"_n, sysio::slug_name, code_row>; + + [[sysio::action]] + void reg(sysio::slug_name code, uint64_t amount) { + codes tbl(get_self()); + tbl.emplace(get_self(), code, {amount}); + } +}; diff --git a/tests/toolchain/abigen-pass/slug_name_direct_kv_key.json b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.json new file mode 100644 index 000000000..236c07ea6 --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.json @@ -0,0 +1 @@ +{ "tests": [{ "expected": { "abi-file": "slug_name_direct_kv_key.abi" } }] } diff --git a/tests/unit/slug_name_tests.cpp b/tests/unit/slug_name_tests.cpp index 9a4d84396..01f54fc0d 100644 --- a/tests/unit/slug_name_tests.cpp +++ b/tests/unit/slug_name_tests.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include @@ -129,6 +130,32 @@ SYSIO_TEST_BEGIN(slug_name_to_key_writes_eight_bytes) CHECK_EQUAL( be, code.value ) SYSIO_TEST_END +SYSIO_TEST_BEGIN(slug_name_datastream_round_trip) + // A DERIVED basic_name needs its OWN exact-match operator<>. The base's hidden friend takes + // `const basic_name&`, so reaching it from a slug_name requires a derived-to-base conversion -- + // and the generic class-template overload, matching exactly, wins instead. That overload hands + // the type to the bluegrass::meta field iterator, which refuses anything with a user-declared + // constructor ("Types with user specified constructors are not supported"). Clang tolerates it; + // GCC 13 does not, and add_native_contract() builds with the HOST compiler -- so a GCC-native + // contract taking a slug_name action parameter would not compile. SYSLIB_SERIALIZE_DERIVED_EMPTY + // supplies the exact match; this test is what keeps it present. + // + // The bytes must also stay identical to the base's, since the host decodes them as one uint64. + const sysio::slug_name code{"LIQSOL"}; + const std::vector packed = sysio::pack(code); + CHECK_EQUAL( packed.size(), 8 ) + CHECK_EQUAL( packed, sysio::pack(code.value) ) + + const auto decoded = sysio::unpack(packed.data(), packed.size()); + CHECK_EQUAL( decoded.value, code.value ) + CHECK_EQUAL( decoded.to_string(), std::string{"LIQSOL"} ) + + // The zero sentinel round-trips too -- it is a legal, renderable value (""). + const sysio::slug_name zero{}; + const std::vector zero_packed = sysio::pack(zero); + CHECK_EQUAL( sysio::unpack(zero_packed.data(), zero_packed.size()).value, 0ull ) +SYSIO_TEST_END + int main(int argc, char* argv[]) { SYSIO_TEST(slug_name_byte_identity_with_the_host) SYSIO_TEST(slug_name_round_trips_canonical_spellings) @@ -137,5 +164,6 @@ int main(int argc, char* argv[]) { SYSIO_TEST(slug_name_canonical_values_are_at_or_above_the_2_42_floor) SYSIO_TEST(slug_name_groups_shared_prefixes_in_the_high_bits) SYSIO_TEST(slug_name_to_key_writes_eight_bytes) + SYSIO_TEST(slug_name_datastream_round_trip) return has_failed(); } From 9d73439dbcea189ba0f850c93a366e2717375b05 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 22 Sep 2026 07:49:33 -0500 Subject: [PATCH 07/12] fix(sysiolib): make check.hpp self-contained; diagnose builtin collisions before the type short-circuit check.hpp declares its intrinsics with uint32_t/uint64_t but did not include , so a TU whose FIRST include is reached it through basic_name.hpp before anything supplied the fixed-width types and failed on host GCC. Every test includes first, which masked it. add_type() short-circuits on any type whose translated spelling is builtin, so a contract-declared record or enum named after one was never described and never reached add_struct's guard: the ABI named the field with the builtin's spelling while the dispatcher kept the declaration's real layout. The check now runs before that short-circuit and is declaration-aware -- sysio:: declares the builtins that are real types and std:: is where string comes from, so a colliding spelling outside both is the author's own. Change-Id: Ibe20a0ef241c01abf0ce418d0dfe292abaf45c9f --- libraries/sysiolib/core/sysio/check.hpp | 1 + plugins/sysio/abigen.hpp | 38 +++++++++++++++++++ plugins/sysio/gen.hpp | 5 +++ .../builtin_type_name_collision_decl.cpp | 34 +++++++++++++++++ .../builtin_type_name_collision_decl.json | 10 +++++ 5 files changed, 88 insertions(+) create mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision_decl.cpp create mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision_decl.json diff --git a/libraries/sysiolib/core/sysio/check.hpp b/libraries/sysiolib/core/sysio/check.hpp index 7f9b3883f..0238b505f 100644 --- a/libraries/sysiolib/core/sysio/check.hpp +++ b/libraries/sysiolib/core/sysio/check.hpp @@ -4,6 +4,7 @@ */ #pragma once +#include #include #include diff --git a/plugins/sysio/abigen.hpp b/plugins/sysio/abigen.hpp index d85c951b3..b9b77b9b5 100644 --- a/plugins/sysio/abigen.hpp +++ b/plugins/sysio/abigen.hpp @@ -912,6 +912,43 @@ namespace sysio { namespace cdt { return ret; } + /// Refuse a contract-declared record or enum whose ABI spelling collides with a builtin. + /// + /// This has to run BEFORE the builtin short-circuit below, which is what makes the + /// collision invisible: `add_type` returns for any type whose translated spelling is + /// builtin, so such a declaration is never described and never reaches `add_struct`'s own + /// guard. The ABI then names the field with the builtin's spelling while the generated + /// dispatcher still reads the declaration's real layout -- the host consumes 8 bytes for + /// `slug_name` where the contract wrote 12, with nothing failing at build or deploy time. + /// + /// Only a record or enum can collide this way: a primitive has no declaration to check, + /// and an alias goes through `is_aliasing`. `sysio::` is where CDT declares the builtins + /// that are real types, and `std::` is where `string` comes from; a colliding spelling + /// outside both is the author's own. + void check_no_builtin_collision( const clang::QualType& type ) { + const std::string spelling = translate_type(type); + if (!is_builtin_type(spelling)) + return; + + const clang::NamedDecl* decl = type.getTypePtr()->getAsCXXRecordDecl(); + if (!decl && type.getTypePtr()->isEnumeralType()) { + if (const auto* et = llvm::dyn_cast(type.getCanonicalType().getTypePtr())) + decl = et->getDecl(); + } + if (!decl) + return; // a primitive: no declaration can collide + + const std::string qualified = decl->getQualifiedNameAsString(); + if (qualified.rfind(builtin_namespace_prefix, 0) == 0 || + qualified.rfind(std_namespace_prefix, 0) == 0) + return; + + CDT_CHECK_ERROR(false, "abigen_error", decl->getLocation(), + "'" + qualified + "' collides with the built-in ABI type '" + spelling + "'; the ABI " + "cannot describe it, and the host would resolve the builtin's layout in its place -- " + "rename this type"); + } + void add_type( const clang::QualType& t ) { if (evaluated.count(t.getTypePtr())) return; @@ -921,6 +958,7 @@ namespace sysio { namespace cdt { add_explicit_nested_type(t.getNonReferenceType()); return; } + check_no_builtin_collision(type); if (!is_builtin_type(translate_type(type))) { // Handle C++ enums (both scoped `enum class` and unscoped `enum`) // by creating an enum_def with member names and values. diff --git a/plugins/sysio/gen.hpp b/plugins/sysio/gen.hpp index 25e285ac3..708b09b2a 100644 --- a/plugins/sysio/gen.hpp +++ b/plugins/sysio/gen.hpp @@ -834,6 +834,11 @@ struct generation_utils { /// colliding with it, which abigen must diagnose rather than silently drop. static constexpr auto builtin_namespace_prefix = "sysio::"; + /// The other namespace a builtin spelling can legitimately come from: `string` is + /// `std::basic_string`. A record or enum outside both namespaces whose stripped spelling is a + /// builtin is the contract author's own type colliding with one. + static constexpr auto std_namespace_prefix = "std::"; + inline bool is_builtin_type( const std::string& t ) { static const std::set builtins = { diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.cpp b/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.cpp new file mode 100644 index 000000000..41f76313b --- /dev/null +++ b/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.cpp @@ -0,0 +1,34 @@ +// A REFERENCED declaration whose ABI spelling collides with a builtin must be a diagnostic. +// +// Sibling of `builtin_type_name_collision`, which covers an action METHOD named after a builtin. +// This is the other half, and it takes a different path: `add_type()` short-circuits on any type +// whose translated, namespace-stripped spelling is builtin, so a user declaration named +// `slug_name` is never described and never reaches `add_struct`'s guard at all. The contract +// compiled with exit 0 and emitted `payload` as type `slug_name` with no struct for it -- so with +// wire-sysio#619 installed the host resolves the BUILTIN (8 bytes) while the generated dispatcher +// still deserializes uint64 + uint32 (12 bytes). Silent, and unusable. +// +// The check therefore runs BEFORE that short-circuit, and is declaration-aware: `sysio::` is +// where CDT declares the builtins that are real types and `std::` is where `string` comes from, +// so a record or enum outside both whose spelling collides is the author's own. An `enum class +// slug_name` fails identically -- same path, same diagnostic. +#include + +using namespace sysio; + +class [[sysio::contract("builtin_type_name_collision_decl")]] builtin_type_name_collision_decl + : public contract { +public: + using contract::contract; + + struct slug_name { + uint64_t id; + uint32_t flags; + SYSLIB_SERIALIZE(slug_name, (id)(flags)) + }; + + [[sysio::action]] + void reg(slug_name payload) { + print(payload.id); + } +}; diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.json b/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.json new file mode 100644 index 000000000..2fb877d59 --- /dev/null +++ b/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.json @@ -0,0 +1,10 @@ +{ + "tests": [ + { + "compile_flags": ["--abigen"], + "expected": { + "stderr": "collides with the built-in ABI type 'slug_name'" + } + } + ] +} From 0eb9eb9d6da939c946776ffd41ff61a07e4922cd Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 22 Sep 2026 09:12:16 -0500 Subject: [PATCH 08/12] revert(abigen): drop the builtin-name collision diagnostic A user type whose name collides with an ABI builtin has always been dropped silently. `struct name { uint64_t id; uint32_t flags; }` used as an action parameter compiles clean on the PRE-PR compiler and emits `payload: name` with no struct for it -- identical to what was reported for slug_name. The condition belongs to the builtin set, not to this type, and no other builtin diagnoses it. The guard added here tried to, and produced a regression each round: typedef aliases, explicit nested containers and nested namespaces all bypassed it while the pre-PR compiler handled them correctly. Reverting leaves slug_name behaving exactly like sysio::name, which is the bar this PR should meet. The serializer, the direct-key leaf and the check.hpp include are unaffected. Change-Id: I426c9a29d4b2eb6cfbebc01968a3f682e9a28778 --- plugins/sysio/abigen.hpp | 61 +------------------ plugins/sysio/gen.hpp | 11 ---- .../builtin_type_name_collision.cpp | 31 ---------- .../builtin_type_name_collision.json | 10 --- .../builtin_type_name_collision_decl.cpp | 34 ----------- .../builtin_type_name_collision_decl.json | 10 --- 6 files changed, 1 insertion(+), 156 deletions(-) delete mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision.cpp delete mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision.json delete mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision_decl.cpp delete mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision_decl.json diff --git a/plugins/sysio/abigen.hpp b/plugins/sysio/abigen.hpp index b9b77b9b5..7cd6edf96 100644 --- a/plugins/sysio/abigen.hpp +++ b/plugins/sysio/abigen.hpp @@ -297,21 +297,8 @@ namespace sysio { namespace cdt { // path adds a table's key struct so clients can reference it, which is // right for a composite key and wrong for one that is already a builtin. const std::string emitted_name = rname.empty() ? decl->getName().str() : rname; - if ( is_builtin_type(emitted_name) ) { - // CDT's OWN builtin type -- suppress it and its base, per above. - if ( decl->getQualifiedNameAsString() == builtin_namespace_prefix + emitted_name ) - return; - // A DIFFERENT type whose emitted name collides with a builtin. Describing it makes - // the host reject the ABI (duplicate_abi_type_def_exception); dropping it silently - // -- which `validate_struct` would otherwise do -- leaves an ABI that names the - // type while the host resolves the BUILTIN's shape for it, so a client packs one - // layout and the contract unpacks another. Neither is recoverable at run time, so - // refuse here, where the author can still rename. - CDT_CHECK_ERROR(false, "abigen_error", decl->getLocation(), - "'" + emitted_name + "' collides with the built-in ABI type of the same name; " - "rename this type"); + if ( is_builtin_type(emitted_name) ) return; - } abi_struct ret; if ( decl->getNumBases() == 1 ) { ret.base = get_type(decl->bases_begin()->getType()); @@ -357,14 +344,6 @@ namespace sysio { namespace cdt { } abi_struct new_struct; new_struct.name = decl->getNameAsString(); - // The wrapper is named after the METHOD, so an action method named after a builtin - // produces a struct the ABI cannot carry: `validate_struct` drops it, leaving an action - // whose `type` names the builtin. The host then resolves the builtin's shape -- one - // 8-byte slug for `slug_name` -- while the generated dispatcher still deserializes the - // real parameter list, so the action is silently unusable. Refuse at compile time. - CDT_CHECK_ERROR(!is_builtin_type(new_struct.name), "abigen_error", decl->getLocation(), - "action method '" + new_struct.name + "' collides with the built-in ABI type of the " - "same name; rename the method (the [[sysio::action(\"...\")]] name may stay)"); for (auto param : decl->parameters() ) { auto param_type = param->getType().getNonReferenceType().getUnqualifiedType(); new_struct.fields.push_back({param->getNameAsString(), get_type(param_type)}); @@ -912,43 +891,6 @@ namespace sysio { namespace cdt { return ret; } - /// Refuse a contract-declared record or enum whose ABI spelling collides with a builtin. - /// - /// This has to run BEFORE the builtin short-circuit below, which is what makes the - /// collision invisible: `add_type` returns for any type whose translated spelling is - /// builtin, so such a declaration is never described and never reaches `add_struct`'s own - /// guard. The ABI then names the field with the builtin's spelling while the generated - /// dispatcher still reads the declaration's real layout -- the host consumes 8 bytes for - /// `slug_name` where the contract wrote 12, with nothing failing at build or deploy time. - /// - /// Only a record or enum can collide this way: a primitive has no declaration to check, - /// and an alias goes through `is_aliasing`. `sysio::` is where CDT declares the builtins - /// that are real types, and `std::` is where `string` comes from; a colliding spelling - /// outside both is the author's own. - void check_no_builtin_collision( const clang::QualType& type ) { - const std::string spelling = translate_type(type); - if (!is_builtin_type(spelling)) - return; - - const clang::NamedDecl* decl = type.getTypePtr()->getAsCXXRecordDecl(); - if (!decl && type.getTypePtr()->isEnumeralType()) { - if (const auto* et = llvm::dyn_cast(type.getCanonicalType().getTypePtr())) - decl = et->getDecl(); - } - if (!decl) - return; // a primitive: no declaration can collide - - const std::string qualified = decl->getQualifiedNameAsString(); - if (qualified.rfind(builtin_namespace_prefix, 0) == 0 || - qualified.rfind(std_namespace_prefix, 0) == 0) - return; - - CDT_CHECK_ERROR(false, "abigen_error", decl->getLocation(), - "'" + qualified + "' collides with the built-in ABI type '" + spelling + "'; the ABI " - "cannot describe it, and the host would resolve the builtin's layout in its place -- " - "rename this type"); - } - void add_type( const clang::QualType& t ) { if (evaluated.count(t.getTypePtr())) return; @@ -958,7 +900,6 @@ namespace sysio { namespace cdt { add_explicit_nested_type(t.getNonReferenceType()); return; } - check_no_builtin_collision(type); if (!is_builtin_type(translate_type(type))) { // Handle C++ enums (both scoped `enum class` and unscoped `enum`) // by creating an enum_def with member names and values. diff --git a/plugins/sysio/gen.hpp b/plugins/sysio/gen.hpp index 708b09b2a..aee55ddd3 100644 --- a/plugins/sysio/gen.hpp +++ b/plugins/sysio/gen.hpp @@ -828,17 +828,6 @@ struct generation_utils { // --- Builtin / reserved checks --- - /// Namespace every builtin that is a real CDT type (`name`, `slug_name`, `asset`, ...) is - /// declared in. A record whose qualified name is this prefix + a builtin spelling IS that - /// builtin; one that merely shares the unqualified spelling is a contract author's own type - /// colliding with it, which abigen must diagnose rather than silently drop. - static constexpr auto builtin_namespace_prefix = "sysio::"; - - /// The other namespace a builtin spelling can legitimately come from: `string` is - /// `std::basic_string`. A record or enum outside both namespaces whose stripped spelling is a - /// builtin is the contract author's own type colliding with one. - static constexpr auto std_namespace_prefix = "std::"; - inline bool is_builtin_type( const std::string& t ) { static const std::set builtins = { diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp b/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp deleted file mode 100644 index 36f8eb798..000000000 --- a/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp +++ /dev/null @@ -1,31 +0,0 @@ -// An action method named after a builtin ABI type must be a DIAGNOSTIC, not a silent ABI. -// -// The wrapper struct abigen synthesizes for an action is named after the METHOD. Once -// `slug_name` joined the builtin set (wire-cdt #119), a method named `slug_name` produced a -// wrapper whose name is a builtin -- and `validate_struct` drops any such struct. The ABI then -// carried the action with `type: "slug_name"` and `structs: []`, so the host resolved the -// BUILTIN's shape (one 8-byte slug) while the generated dispatcher still deserialized the real -// parameter list (uint64 + uint32, 12 bytes). Nothing failed at build time and nothing failed at -// deploy time; the action was simply unusable, and no error pointed at why. -// -// Emitting the wrapper instead is not an option either -- the host rejects an ABI that defines a -// type it already knows intrinsically (`duplicate_abi_type_def_exception`). Since neither -// outcome is recoverable at run time, abigen refuses at compile time, where renaming the method -// costs nothing. The [[sysio::action("...")]] NAME is unaffected; only the C++ method spelling -// has to move. -#include -#include - -using namespace sysio; - -class [[sysio::contract("builtin_type_name_collision")]] builtin_type_name_collision - : public contract { -public: - using contract::contract; - - [[sysio::action("regslug")]] - void slug_name(uint64_t id, uint32_t flags) { - check(flags != 0, "flags must be set"); - print(id); - } -}; diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision.json b/tests/toolchain/abigen-fail/builtin_type_name_collision.json deleted file mode 100644 index 9f58ac939..000000000 --- a/tests/toolchain/abigen-fail/builtin_type_name_collision.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "tests": [ - { - "compile_flags": ["--abigen"], - "expected": { - "stderr": "collides with the built-in ABI type of the same name" - } - } - ] -} diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.cpp b/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.cpp deleted file mode 100644 index 41f76313b..000000000 --- a/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.cpp +++ /dev/null @@ -1,34 +0,0 @@ -// A REFERENCED declaration whose ABI spelling collides with a builtin must be a diagnostic. -// -// Sibling of `builtin_type_name_collision`, which covers an action METHOD named after a builtin. -// This is the other half, and it takes a different path: `add_type()` short-circuits on any type -// whose translated, namespace-stripped spelling is builtin, so a user declaration named -// `slug_name` is never described and never reaches `add_struct`'s guard at all. The contract -// compiled with exit 0 and emitted `payload` as type `slug_name` with no struct for it -- so with -// wire-sysio#619 installed the host resolves the BUILTIN (8 bytes) while the generated dispatcher -// still deserializes uint64 + uint32 (12 bytes). Silent, and unusable. -// -// The check therefore runs BEFORE that short-circuit, and is declaration-aware: `sysio::` is -// where CDT declares the builtins that are real types and `std::` is where `string` comes from, -// so a record or enum outside both whose spelling collides is the author's own. An `enum class -// slug_name` fails identically -- same path, same diagnostic. -#include - -using namespace sysio; - -class [[sysio::contract("builtin_type_name_collision_decl")]] builtin_type_name_collision_decl - : public contract { -public: - using contract::contract; - - struct slug_name { - uint64_t id; - uint32_t flags; - SYSLIB_SERIALIZE(slug_name, (id)(flags)) - }; - - [[sysio::action]] - void reg(slug_name payload) { - print(payload.id); - } -}; diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.json b/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.json deleted file mode 100644 index 2fb877d59..000000000 --- a/tests/toolchain/abigen-fail/builtin_type_name_collision_decl.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "tests": [ - { - "compile_flags": ["--abigen"], - "expected": { - "stderr": "collides with the built-in ABI type 'slug_name'" - } - } - ] -} From 9b3bbeb89ddc1e2d6b63771fe91701a3a7213233 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 22 Sep 2026 10:20:37 -0500 Subject: [PATCH 09/12] fix(sysiolib): bind Traits::alphabet to a view before using it The traits concept requires only convertible-to-string_view, but validity_error used find()/operator[] on the traits member directly and the symbol-width derivation used size(). One private binding now serves every use, as the concept already does for leading_alphabet. basic_name_tests adds a policy whose alphabet is only convertible: it fails to compile at all three sites without the binding. Change-Id: Id7c2e203fbd16246047530f3442c38043898b797 --- libraries/sysiolib/core/sysio/basic_name.hpp | 28 +++++++------ tests/unit/basic_name_tests.cpp | 43 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/libraries/sysiolib/core/sysio/basic_name.hpp b/libraries/sysiolib/core/sysio/basic_name.hpp index 253a808ff..0094ce842 100644 --- a/libraries/sysiolib/core/sysio/basic_name.hpp +++ b/libraries/sysiolib/core/sysio/basic_name.hpp @@ -137,7 +137,7 @@ struct basic_name { } for ( std::size_t i = 0; i < str.size(); ++i ) { - const std::size_t sym = Traits::alphabet.find( str[i] ); + const std::size_t sym = alphabet.find( str[i] ); // 3. in the alphabet if ( sym == std::string_view::npos ) @@ -160,7 +160,7 @@ struct basic_name { // 6. a non-zero-terminated alphabet strips TRAILING pads in to_string(), // so a trailing pad cannot round-trip either. if constexpr ( !Traits::zero_terminates ) { - if ( !str.empty() && str.back() == Traits::alphabet[0] ) + if ( !str.empty() && str.back() == alphabet[0] ) return Traits::not_normalized_message; } @@ -205,16 +205,14 @@ struct basic_name { /// character -> symbol; sysio::check-throws on a character outside the /// alphabet. (sysio::name re-exposes this as char_to_value.) static constexpr uint64_t symbol( char c ) { - const std::string_view a = Traits::alphabet; - for ( std::size_t s = 0; s < a.size(); ++s ) - if ( a[s] == c ) return static_cast(s); + for ( std::size_t s = 0; s < alphabet.size(); ++s ) + if ( alphabet[s] == c ) return static_cast(s); sysio::check( false, Traits::bad_char_message ); return 0; // unreachable } /// symbol -> character; out-of-range symbols decode as the pad (alphabet[0]). static constexpr char character( uint64_t s ) { - const std::string_view a = Traits::alphabet; - return s < a.size() ? a[s] : a[0]; + return s < alphabet.size() ? alphabet[s] : alphabet[0]; } // Total order on the packed value. With MSB packing this matches the @@ -233,13 +231,19 @@ struct basic_name { CDT_REFLECT(value); private: + /// `Traits::alphabet` as a view. The traits concept requires only that the + /// member be CONVERTIBLE to string_view, so every use binds here rather than + /// calling find/size/operator[] on the traits member and silently demanding + /// more of a policy than the concept declares. Mirrors fc::basic_name. + static constexpr std::string_view alphabet = Traits::alphabet; + // --- symbol width: minimal bits to index the alphabet --- static constexpr int symbol_bits( std::size_t alphabet_size ) { int b = 0; while ( (std::size_t{1} << b) < alphabet_size ) ++b; return b; } - static constexpr int bits = symbol_bits( Traits::alphabet.size() ); + static constexpr int bits = symbol_bits( alphabet.size() ); static constexpr int total_bits = Traits::max_len * bits < 64 ? Traits::max_len * bits : 64; static_assert( (Traits::max_len - 1) * bits < 64, @@ -250,17 +254,15 @@ struct basic_name { /// symbol()/character() below stay as this type's PUBLIC surface, which /// sysio::name re-exposes as char_to_value. static constexpr char char_of( uint64_t s ) { - const std::string_view a = Traits::alphabet; - return s < a.size() ? a[s] : a[0]; + return s < alphabet.size() ? alphabet[s] : alphabet[0]; } /// character -> symbol, NON-throwing: any character outside the alphabet /// maps to 0. Used by pack(), which is non-validating by contract; callers /// that need rejection go through validity_error(). Mirrors fc's sym_of. static constexpr uint64_t sym_of( char c ) { - const std::string_view a = Traits::alphabet; - for ( std::size_t s = 0; s < a.size(); ++s ) - if ( a[s] == c ) return static_cast(s); + for ( std::size_t s = 0; s < alphabet.size(); ++s ) + if ( alphabet[s] == c ) return static_cast(s); return 0; } diff --git a/tests/unit/basic_name_tests.cpp b/tests/unit/basic_name_tests.cpp index 1b0100d7e..cfecd5930 100644 --- a/tests/unit/basic_name_tests.cpp +++ b/tests/unit/basic_name_tests.cpp @@ -77,6 +77,32 @@ struct incomplete_traits { static constexpr const char* not_normalized_message = "x"; }; +// An alphabet member that is only CONVERTIBLE to std::string_view: it has no +// find(), no size(), no operator[] of its own. basic_name_traits asks for +// exactly this much, so basic_name must bind a view before using it -- a direct +// member call on the traits' alphabet would not compile against this policy. +struct convertible_alphabet { + static constexpr char storage[] = ".12345abcdefghijklmnopqrstuvwxyz"; + constexpr operator std::string_view() const { + return { storage, sizeof(storage) - 1 }; + } +}; + +// Name-style (zero_terminates = false) so ONE instantiation reaches all three +// sites: the alphabet scan in validity_error, rule 6's trailing-pad test (which +// indexes alphabet[0]), and the symbol-width derivation (which takes size()). +struct convertible_alphabet_traits { + static constexpr int max_len = 13; + static constexpr convertible_alphabet alphabet{}; + static constexpr bool zero_terminates = false; + static constexpr basic_name_endianness packing = basic_name_endianness::MSB; + static constexpr const char* bad_char_message = "conv: character is not in the alphabet"; + static constexpr const char* too_long_message = "conv: string is too long"; + static constexpr const char* bad_final_symbol_message = "conv: final symbol does not fit its slot"; + static constexpr const char* not_normalized_message = "conv: spelling is not properly normalized"; +}; +using test_conv = basic_name; + } // namespace // basic_name_traits concept: real policies satisfy it, an incomplete one does not. @@ -88,6 +114,22 @@ SYSIO_TEST_BEGIN(basic_name_test_concept) CHECK_EQUAL( basic_name_traits, false ) SYSIO_TEST_END +// A policy whose alphabet is only CONVERTIBLE to string_view is usable. The +// concept promises no more than that, so the implementation must not demand +// more; this whole test is a compile-time assertion as much as a runtime one. +SYSIO_TEST_BEGIN(basic_name_test_convertible_alphabet) + static_assert( basic_name_traits ); + // Round trip: the alphabet scan and the symbol-width derivation both ran. + CHECK_EQUAL( test_conv{"sysio"}.to_string(), "sysio" ) + CHECK_EQUAL( test_conv{"a.b"}.to_string(), "a.b" ) + // Rule 6 (!zero_terminates): a trailing pad is not normalized. This is the + // check that indexes alphabet[0]. + CHECK_ASSERT( "conv: spelling is not properly normalized", + ([]() { test_conv{"a."}; }) ) + CHECK_ASSERT( "conv: character is not in the alphabet", + ([]() { test_conv{"A"}; }) ) +SYSIO_TEST_END + // A slug round-trips: string -> packed uint64 -> string. SYSIO_TEST_BEGIN(basic_name_test_slug_roundtrip) CHECK_EQUAL( test_slug{""}.to_string(), "" ) @@ -243,6 +285,7 @@ int main(int argc, char* argv[]) { silence_output(!verbose); SYSIO_TEST(basic_name_test_concept) + SYSIO_TEST(basic_name_test_convertible_alphabet) SYSIO_TEST(basic_name_test_slug_roundtrip) SYSIO_TEST(basic_name_test_slug_zero_terminates) SYSIO_TEST(basic_name_test_slug_compare) From b1298b3fe62b715a174d8365ca26a57dc7ab5df5 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 23 Sep 2026 08:17:52 -0500 Subject: [PATCH 10/12] refactor(sysiolib): move is_canonical onto slug_name; pin the shape is_canonical is meaningless on `name`: that alphabet is exactly 2^5 with no gaps and its 13 symbols consume all 64 bits, so every uint64 IS a canonical name and the predicate can never be false. It belongs to slug_name's encoding, which leaves 26 symbol values unused, terminates on symbol 0, and never reads bits 48-63. Two static_asserts pin the shape, because it drifted silently once: this repo derived slug_name so abigen would match the builtin, while wire-sysio kept an alias, and is_canonical sat on the base they share. wire-sysio carries the same pair, so either side drifting is a compile error. ctest 35/35. Change-Id: If500b02a829b2a4c92f258852cc0199198d625f3 --- libraries/sysiolib/core/sysio/basic_name.hpp | 12 -------- libraries/sysiolib/core/sysio/slug_name.hpp | 32 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/libraries/sysiolib/core/sysio/basic_name.hpp b/libraries/sysiolib/core/sysio/basic_name.hpp index 0094ce842..a575f223e 100644 --- a/libraries/sysiolib/core/sysio/basic_name.hpp +++ b/libraries/sysiolib/core/sysio/basic_name.hpp @@ -172,18 +172,6 @@ struct basic_name { constexpr bool good() const { return value != 0; } constexpr explicit operator bool() const { return value != 0; } - /// Does this value have a canonical spelling? A basic_name built from a RAW - /// uint64 bypasses the validating constructor, so it can hold a value no - /// spelling produces — for zero_terminates traits, anything whose leading - /// symbol slot is empty. Such a value cannot round-trip: to_string() yields a - /// text that packs to something else. Persisting one makes every later render - /// of that row throw, so writers that accept a raw uint64 off the wire gate on - /// this before storing it. - bool is_canonical() const { - const std::string text = to_string(); - return is_valid_literal(text) && pack(text) == value; - } - std::string to_string() const { std::string s; for ( int i = 0; i < Traits::max_len; ++i ) { diff --git a/libraries/sysiolib/core/sysio/slug_name.hpp b/libraries/sysiolib/core/sysio/slug_name.hpp index af7cceb4e..2f16ec3bd 100644 --- a/libraries/sysiolib/core/sysio/slug_name.hpp +++ b/libraries/sysiolib/core/sysio/slug_name.hpp @@ -4,6 +4,7 @@ #include "name.hpp" // for sysio::detail::to_const_char_arr #include +#include namespace sysio { @@ -89,8 +90,39 @@ namespace sysio { /// builds with the HOST compiler and its generated dispatcher deserializes action arguments /// through this path. Forwarding to the base keeps the bytes identical to basic_name's. SYSLIB_SERIALIZE_DERIVED_EMPTY( slug_name, base ) + + /// Does this value have a canonical spelling? A slug_name built from a RAW uint64 bypasses the + /// validating constructor -- and nothing validates on deserialization either, since the + /// reflected member is written directly -- so it can hold a value no spelling produces: + /// anything whose leading symbol slot is empty, or that uses one of the 26 unused symbol + /// values, or that sets any of bits 48-63. Such a value cannot round-trip. + /// + /// This lives on slug_name and NOT on basic_name because it is meaningless for `name`: that + /// alphabet is exactly 2^5 with no gaps and its 13 symbols consume all 64 bits, so every + /// uint64 IS a canonical name and the predicate could never be false. + bool is_canonical() const { + const std::string text = to_string(); + return is_valid_literal(text) && pack(text) == value; + } }; + // --- shape pins --------------------------------------------------------- + // These two properties drifted apart between this repo and wire-sysio once before, silently: + // CDT derived slug_name for abigen while the host side stayed an alias, and is_canonical sat on + // the shared base where `name` inherited a predicate that can never be false. Both repos assert + // the same two things. + static_assert(!std::is_same_v>, + "slug_name must be a DERIVED type, not an alias -- abigen matches builtins on a " + "real type, and is_canonical belongs to this encoding"); + template + concept has_is_canonical = requires(const T t) { t.is_canonical(); }; + + static_assert(!has_is_canonical>, + "is_canonical must live on slug_name, not the shared basic_name: `name` shares " + "that base, and every uint64 IS a canonical name, so the predicate could never " + "be false there"); + static_assert(has_is_canonical, "slug_name must carry is_canonical"); + } // namespace sysio /** From 1eb3352c8bd9c4071241b76e1c163836ff861522 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 23 Sep 2026 08:32:04 -0500 Subject: [PATCH 11/12] refactor(sysiolib): drop the slug_name shape pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They asserted the two properties that had already drifted, which is not where the next drift will be, and the has_is_canonical concept existed only to feed them — public surface with no runtime value. Both properties are enforced by use anyway: deriving slug_name is what makes abigen match the builtin, and is_canonical only compiles against the derived type. ctest 35/35. Change-Id: I8600293d940d0e6860142bceee4ccad214fe426c --- libraries/sysiolib/core/sysio/slug_name.hpp | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/libraries/sysiolib/core/sysio/slug_name.hpp b/libraries/sysiolib/core/sysio/slug_name.hpp index 2f16ec3bd..9311217f7 100644 --- a/libraries/sysiolib/core/sysio/slug_name.hpp +++ b/libraries/sysiolib/core/sysio/slug_name.hpp @@ -4,7 +4,6 @@ #include "name.hpp" // for sysio::detail::to_const_char_arr #include -#include namespace sysio { @@ -106,23 +105,6 @@ namespace sysio { } }; - // --- shape pins --------------------------------------------------------- - // These two properties drifted apart between this repo and wire-sysio once before, silently: - // CDT derived slug_name for abigen while the host side stayed an alias, and is_canonical sat on - // the shared base where `name` inherited a predicate that can never be false. Both repos assert - // the same two things. - static_assert(!std::is_same_v>, - "slug_name must be a DERIVED type, not an alias -- abigen matches builtins on a " - "real type, and is_canonical belongs to this encoding"); - template - concept has_is_canonical = requires(const T t) { t.is_canonical(); }; - - static_assert(!has_is_canonical>, - "is_canonical must live on slug_name, not the shared basic_name: `name` shares " - "that base, and every uint64 IS a canonical name, so the predicate could never " - "be false there"); - static_assert(has_is_canonical, "slug_name must carry is_canonical"); - } // namespace sysio /** From 3b340d0f97338a1a96215d60a4ff1ac70a6163c0 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 24 Sep 2026 09:56:37 -0500 Subject: [PATCH 12/12] fix(abigen): diagnose an action method named after an ABI builtin The action wrapper is named after the method, so a method named after a builtin yields a struct validate_struct drops; the action's type then names the builtin and the host reads its layout instead of the parameter list. Restores the wrapper-path diagnostic and its fixture, reverted with the leaky declaration guard in 0eb9eb9d; this path has no alias or nested route around it. Change-Id: If39429a8adb8479c71b2131f7ad8d291ac12ba2c --- plugins/sysio/abigen.hpp | 8 +++++ .../builtin_type_name_collision.cpp | 31 +++++++++++++++++++ .../builtin_type_name_collision.json | 10 ++++++ 3 files changed, 49 insertions(+) create mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision.cpp create mode 100644 tests/toolchain/abigen-fail/builtin_type_name_collision.json diff --git a/plugins/sysio/abigen.hpp b/plugins/sysio/abigen.hpp index 7cd6edf96..b9a2d87a6 100644 --- a/plugins/sysio/abigen.hpp +++ b/plugins/sysio/abigen.hpp @@ -344,6 +344,14 @@ namespace sysio { namespace cdt { } abi_struct new_struct; new_struct.name = decl->getNameAsString(); + // The wrapper is named after the METHOD, so an action method named after a builtin + // produces a struct the ABI cannot carry: `validate_struct` drops it, leaving an action + // whose `type` names the builtin. The host then resolves the builtin's shape -- one + // 8-byte slug for `slug_name` -- while the generated dispatcher still deserializes the + // real parameter list, so the action is silently unusable. Refuse at compile time. + CDT_CHECK_ERROR(!is_builtin_type(new_struct.name), "abigen_error", decl->getLocation(), + "action method '" + new_struct.name + "' collides with the built-in ABI type of the " + "same name; rename the method (the [[sysio::action(\"...\")]] name may stay)"); for (auto param : decl->parameters() ) { auto param_type = param->getType().getNonReferenceType().getUnqualifiedType(); new_struct.fields.push_back({param->getNameAsString(), get_type(param_type)}); diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp b/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp new file mode 100644 index 000000000..36f8eb798 --- /dev/null +++ b/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp @@ -0,0 +1,31 @@ +// An action method named after a builtin ABI type must be a DIAGNOSTIC, not a silent ABI. +// +// The wrapper struct abigen synthesizes for an action is named after the METHOD. Once +// `slug_name` joined the builtin set (wire-cdt #119), a method named `slug_name` produced a +// wrapper whose name is a builtin -- and `validate_struct` drops any such struct. The ABI then +// carried the action with `type: "slug_name"` and `structs: []`, so the host resolved the +// BUILTIN's shape (one 8-byte slug) while the generated dispatcher still deserialized the real +// parameter list (uint64 + uint32, 12 bytes). Nothing failed at build time and nothing failed at +// deploy time; the action was simply unusable, and no error pointed at why. +// +// Emitting the wrapper instead is not an option either -- the host rejects an ABI that defines a +// type it already knows intrinsically (`duplicate_abi_type_def_exception`). Since neither +// outcome is recoverable at run time, abigen refuses at compile time, where renaming the method +// costs nothing. The [[sysio::action("...")]] NAME is unaffected; only the C++ method spelling +// has to move. +#include +#include + +using namespace sysio; + +class [[sysio::contract("builtin_type_name_collision")]] builtin_type_name_collision + : public contract { +public: + using contract::contract; + + [[sysio::action("regslug")]] + void slug_name(uint64_t id, uint32_t flags) { + check(flags != 0, "flags must be set"); + print(id); + } +}; diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision.json b/tests/toolchain/abigen-fail/builtin_type_name_collision.json new file mode 100644 index 000000000..9f58ac939 --- /dev/null +++ b/tests/toolchain/abigen-fail/builtin_type_name_collision.json @@ -0,0 +1,10 @@ +{ + "tests": [ + { + "compile_flags": ["--abigen"], + "expected": { + "stderr": "collides with the built-in ABI type of the same name" + } + } + ] +}