From 60190547b0a80089f7f521aa941651f22ef2abaf Mon Sep 17 00:00:00 2001 From: Justin Kim Date: Thu, 30 Apr 2026 14:40:53 +0900 Subject: [PATCH 1/2] feat: KSUID_NIL_INIT / KSUID_MAX_INIT static-storage initializer macros Closes #1 (commit 1 of 2 in the series). Adds two aggregate-initializer macros next to the existing const sentinels in libksuid/ksuid.h, so callers can write static const ksuid_t my_global = KSUID_NIL_INIT; at static storage on every platform -- including Windows DLL consumers, where KSUID_PUBLIC expands to __declspec(dllimport) and the symbol form (`= KSUID_NIL`) is not a constant expression. Macro spelling is `{ { 0 } }` / `{ { 0xff, ... } }` (double-brace, no designated initializers): C89-clean, MSVC-clean, and quiet under warning_level=3 (-Wmissing-braces / -Wmissing-field-initializers). Designated `{ .b = { 0 } }` would be C99+ and would be rejected by older MSVC modes the project does not need to alienate. Surface added: libksuid/ksuid.h - Compile-time assertion sizeof(ksuid_t) == KSUID_BYTES so a future field added to ksuid_t breaks the build instead of silently zero-initialising past the macro's reach. Spelling gates on __cplusplus: C uses _Static_assert (C11 keyword), C++ uses static_assert (C++11 keyword) -- the assertion lives inside the extern "C" block, where _Static_assert is a C-only token that g++ rejects. - KSUID_NIL_INIT, KSUID_MAX_INIT. - Doc block above the sentinels stating the contract: use the symbol form for runtime comparison, the macro form for static initialization. Calls out the Windows DLL constraint by name. tests/test_smoke.c - File-scope kStaticNilInit / kStaticMaxInit declared from the macros: this is the codepath the issue is about; if the TU compiles, the macro really is a constant expression. - test_init_macros_match_symbols asserts byte-for-byte parity with the runtime symbols and exercises both file-scope and block-scope static storage paths. tests/test_init_shared.c (NEW) - Companion TU that links against the SHARED library (ksuid_lib.get_shared_lib()) with KSUID_BUILDING explicitly undefined. On Windows this exercises __declspec(dllimport) -- the very codepath issue #1 was filed against, which the static-archive-linked tests do not reach. Linux / macOS runs the same TU through visibility("default") export semantics for uniform coverage. - tests/meson.build wires it up with c_args : ['-UKSUID_BUILDING'] (or '/UKSUID_BUILDING' on MSVC) and link_with : ksuid_lib.get_shared_lib(). 12/12 tests pass on Linux GCC; clang-tidy 22 still reports zero findings on the slimmed surface; gst-indent leaves the working tree unchanged. A C++17 smoke build (g++ -std=c++17 -Wpedantic) of a consumer that uses the new macros at static storage compiles cleanly. The .c symbol definitions still spell their bytes by hand. Commit 2 in this series collapses them to derive from the new macros so the two forms can never drift. --- libksuid/ksuid.h | 36 +++++++++++++++++++++++++++++++ tests/meson.build | 25 ++++++++++++++++++++++ tests/test_init_shared.c | 46 ++++++++++++++++++++++++++++++++++++++++ tests/test_smoke.c | 28 ++++++++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 tests/test_init_shared.c diff --git a/libksuid/ksuid.h b/libksuid/ksuid.h index 6e1d8d4..3dc400b 100644 --- a/libksuid/ksuid.h +++ b/libksuid/ksuid.h @@ -47,6 +47,22 @@ extern "C" uint8_t b[KSUID_BYTES]; } ksuid_t; +/* The static-storage initializer macros below assume ksuid_t is + * exactly its byte array -- no padding, no extra fields. If a future + * change adds a field, the assertion fails at compile time and forces + * the macro author to update KSUID_NIL_INIT / KSUID_MAX_INIT in + * lockstep. C11 spells the assertion `_Static_assert`; C++ since + * C++11 spells it `static_assert`. Gate so the public header + * compiles for both, since this file lives inside extern "C" for + * C++ consumers. */ +#ifdef __cplusplus + static_assert (sizeof (ksuid_t) == KSUID_BYTES, + "ksuid_t must be exactly KSUID_BYTES; KSUID_*_INIT macros depend on it"); +#else + _Static_assert (sizeof (ksuid_t) == KSUID_BYTES, + "ksuid_t must be exactly KSUID_BYTES; KSUID_*_INIT macros depend on it"); +#endif + typedef enum ksuid_err { KSUID_OK = 0, @@ -59,9 +75,29 @@ extern "C" KSUID_ERR_TIME_RANGE = -7 /* unix_seconds outside KSUID epoch range */ } ksuid_err_t; +/* Two forms of the same sentinel values: + * + * - KSUID_NIL / KSUID_MAX (extern const ksuid_t) + * Use these for runtime comparison and parameter passing: + * if (ksuid_compare (&id, &KSUID_NIL) == 0) ... + * + * - KSUID_NIL_INIT / KSUID_MAX_INIT (aggregate-initializer macros) + * Use these as constant expressions in a declaration: + * static const ksuid_t g_zero = KSUID_NIL_INIT; + * The macro form is REQUIRED on Windows DLL builds, where + * KSUID_PUBLIC expands to __declspec(dllimport) and the + * symbol is therefore not a constant expression in user TUs. + * + * The two forms are guaranteed byte-for-byte equal; tests/test_smoke.c + * pins the equivalence with ASSERT_EQ_BYTES. */ KSUID_PUBLIC extern const ksuid_t KSUID_NIL; KSUID_PUBLIC extern const ksuid_t KSUID_MAX; +#define KSUID_NIL_INIT { { 0 } } +#define KSUID_MAX_INIT \ + { { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, \ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff } } + /* -------------------------------------------------------------------------- * Predicates and ordering. * -------------------------------------------------------------------------- */ diff --git a/tests/meson.build b/tests/meson.build index 5eeccdb..b294d70 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -32,6 +32,31 @@ else message('Skipping test_rand_tls -- not available on this host') endif +# Issue #1 (KSUID_NIL_INIT / KSUID_MAX_INIT) is specifically about +# Windows DLL consumers, where KSUID_PUBLIC expands to +# __declspec(dllimport) and the public sentinel symbols stop being +# constant expressions in user TUs. Every other test in this +# directory links the static archive (where KSUID_BUILDING stays +# defined and dllimport never triggers), so they DO NOT exercise +# the regression issue #1 was filed against. +# +# This dedicated TU links the SHARED library and explicitly undefines +# KSUID_BUILDING so the public header sees the dllimport / hidden- +# visibility consumer code path. On Windows this catches the original +# bug; on Linux/macOS it exercises shared-library export semantics +# uniformly. +if cc.get_argument_syntax() == 'msvc' + test_init_shared_args = ['/UKSUID_BUILDING'] +else + test_init_shared_args = ['-UKSUID_BUILDING'] +endif +exe = executable('test_init_shared', 'test_init_shared.c', + include_directories : test_inc, + link_with : ksuid_lib.get_shared_lib(), + c_args : test_init_shared_args, +) +test('test_init_shared', exe, timeout : 30) + # Integration test for the ksuid-gen CLI; only registered when the # CLI is built (default on). meson resolves File objects and build # targets to absolute paths automatically when they appear in test() diff --git a/tests/test_init_shared.c b/tests/test_init_shared.c new file mode 100644 index 0000000..64c5dfb --- /dev/null +++ b/tests/test_init_shared.c @@ -0,0 +1,46 @@ +/* SPDX-License-Identifier: LGPL-3.0-or-later + * + * Companion to test_smoke.c that links against the libksuid SHARED + * library rather than the static archive. This is the Windows DLL + * consumer scenario in miniature: KSUID_PUBLIC expands to + * __declspec(dllimport) here (because KSUID_BUILDING is undefined for + * this TU), and KSUID_NIL / KSUID_MAX are therefore *not* constant + * expressions in this translation unit. If the build of this file + * succeeds, the public KSUID_*_INIT macros really do work as static- + * storage initialisers under the same constraints downstream + * consumers will face. The test then memcmp's against the symbols + * to prove the bytes match. + * + * This file exists because tests/test_smoke.c links the static + * archive (KSUID_BUILDING is in scope, no dllimport, the symbol is a + * normal const) and so does NOT exercise the very codepath issue #1 + * was filed about. */ + +#include +#include "test_util.h" + +/* File-scope static storage from the macro form. On Windows DLL + * consumers the equivalent `static const ksuid_t g = KSUID_NIL;` + * fails with C2099 "initializer is not a constant" -- that is the + * regression we are guarding against. */ +static const ksuid_t kSharedNilInit = KSUID_NIL_INIT; +static const ksuid_t kSharedMaxInit = KSUID_MAX_INIT; + +static void +test_macros_at_static_storage_match_shared_symbols (void) +{ + /* If KSUID_NIL / KSUID_MAX really are dllimport on this build (the + * Windows lane) the runtime symbol resolution lands here at + * comparison time, after the file-scope statics above were already + * frozen at load time from the macro values. */ + ASSERT_EQ_BYTES (kSharedNilInit.b, KSUID_NIL.b, KSUID_BYTES); + ASSERT_EQ_BYTES (kSharedMaxInit.b, KSUID_MAX.b, KSUID_BYTES); + ASSERT_TRUE (ksuid_is_nil (&kSharedNilInit)); +} + +int +main (void) +{ + RUN_TEST (test_macros_at_static_storage_match_shared_symbols); + TEST_MAIN_END (); +} diff --git a/tests/test_smoke.c b/tests/test_smoke.c index 23ebe02..a1259d0 100644 --- a/tests/test_smoke.c +++ b/tests/test_smoke.c @@ -2,6 +2,15 @@ #include #include "test_util.h" +/* File-scope statics initialised from the public macros. The point of + * this test pattern is that exactly this codepath -- aggregate + * initialisation at static storage from a public sentinel -- is what + * fails to compile on Windows DLL with the symbol form (issue #1). If + * this TU compiles, KSUID_*_INIT works as a constant expression on + * the target. The runtime memcmp below proves the bytes are right. */ +static const ksuid_t kStaticNilInit = KSUID_NIL_INIT; +static const ksuid_t kStaticMaxInit = KSUID_MAX_INIT; + static void test_constants_have_expected_layout (void) { @@ -48,6 +57,24 @@ test_compare_first_byte_dominates (void) ASSERT_TRUE (ksuid_compare (&b, &a) < 0); } +static void +test_init_macros_match_symbols (void) +{ + /* File-scope statics: the codepath that fails on Windows DLL today. + * Byte-for-byte parity with the runtime symbols proves the macros + * encode the right constants. */ + ASSERT_EQ_BYTES (kStaticNilInit.b, KSUID_NIL.b, KSUID_BYTES); + ASSERT_EQ_BYTES (kStaticMaxInit.b, KSUID_MAX.b, KSUID_BYTES); + ASSERT_TRUE (ksuid_is_nil (&kStaticNilInit)); + + /* Block-scope static storage: distinct codepath from file scope on + * some compilers, worth pinning separately. */ + static const ksuid_t local_nil = KSUID_NIL_INIT; + static const ksuid_t local_max = KSUID_MAX_INIT; + ASSERT_TRUE (ksuid_is_nil (&local_nil)); + ASSERT_EQ_BYTES (local_max.b, KSUID_MAX.b, KSUID_BYTES); +} + static void test_version_macros_are_consistent (void) { @@ -84,6 +111,7 @@ main (void) RUN_TEST (test_max_is_all_ff); RUN_TEST (test_compare_orders_lex); RUN_TEST (test_compare_first_byte_dominates); + RUN_TEST (test_init_macros_match_symbols); RUN_TEST (test_version_macros_are_consistent); TEST_MAIN_END (); } From 62c6dfe33445b8336bb022946c0c81b373e8f2ab Mon Sep 17 00:00:00 2001 From: Justin Kim Date: Thu, 30 Apr 2026 14:40:53 +0900 Subject: [PATCH 2/2] refactor: define KSUID_NIL/KSUID_MAX from the public initializer macros Closes #1 (commit 2 of 2 in the series). Pure refactor: collapses the hand-written aggregate initializers in libksuid/ksuid.c so both definitions derive from the public KSUID_NIL_INIT / KSUID_MAX_INIT macros that landed in commit 1. KSUID_PUBLIC const ksuid_t KSUID_NIL = KSUID_NIL_INIT; KSUID_PUBLIC const ksuid_t KSUID_MAX = KSUID_MAX_INIT; Two consequences: 1. Single source of truth. The 20 0xff bytes that make up KSUID_MAX now live in exactly one place (libksuid/ksuid.h's KSUID_MAX_INIT). A future contributor cannot accidentally diverge the macro and the symbol. 2. The macro form is exercised at the library's own build site, not just in the consumer's TU. If KSUID_NIL_INIT / KSUID_MAX_INIT ever fail to compile -- e.g. because someone added a field to ksuid_t and forgot to update the macro -- the library itself fails to build, before any test even runs. No public-API or ABI delta; no test changes. The existing test_nil_is_all_zero, test_max_is_all_ff, and the new test_init_macros_match_symbols all pass unchanged, proving the refactor is observationally a no-op. 12/12 tests pass; clang-tidy 22 still reports zero findings. --- libksuid/ksuid.c | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/libksuid/ksuid.c b/libksuid/ksuid.c index ebf4ed0..44be238 100644 --- a/libksuid/ksuid.c +++ b/libksuid/ksuid.c @@ -17,14 +17,14 @@ #include #include -KSUID_PUBLIC const ksuid_t KSUID_NIL = {.b = {0} }; - -KSUID_PUBLIC const ksuid_t KSUID_MAX = { - .b = { - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - }, -}; +/* Drive both definitions from the public KSUID_*_INIT macros so the + * runtime symbols and the static-storage initializer form can never + * drift out of byte-for-byte agreement -- the regression + * test_init_macros_match_symbols pins the same equivalence at runtime + * but a single source of truth at the definition site removes the + * possibility entirely. */ +KSUID_PUBLIC const ksuid_t KSUID_NIL = KSUID_NIL_INIT; +KSUID_PUBLIC const ksuid_t KSUID_MAX = KSUID_MAX_INIT; bool ksuid_is_nil (const ksuid_t *id)