Enforce status-code ownership and harden the name registry

Reservations were advisory bookkeeping: any component could name any status,
so the registry only detected declared-range overlap between components that
both opted in. Naming a status now requires a reservation.
akerr_register_status_name() checks that the range belongs to the caller, and
the legacy two-argument akerr_name_for_status() set path, which cannot
identify its caller, requires that some reservation covers the status. Every
refusal is logged and names the real owner, because a name that fails to
register degrades that code to "Unknown Error" in every later stack trace.

Fix a reservation made before the first PREPARE_ERROR being silently
discarded. akerr_init() clears the tables, so whichever component first
triggered it wiped an earlier reservation and the next component to claim the
same range was told it was free, producing exactly the undetected aliasing
the registry exists to prevent. Every registry entry point now calls
akerr_init(), which sets its guard before doing any work so those calls do
not recurse.

Replace the linear-scan name array with an open-addressed hash table, taking
lookup from O(n) to O(1) and raising usable capacity from 512 entries (366
free to consumers after errno registration) to 3072 (~2900 free). Both table
sizes are build-time overridable and applied PRIVATE: they live entirely in
src/error.c, so raising them cannot desynchronize a library from its
consumers the way AKERR_MAX_ERR_VALUE could. Exhausting either table is now
logged and returned to the caller rather than silently dropping the entry.
No dynamic allocation is introduced; both tables remain file-scope arrays,
and the library's undefined-symbol set gains only strcmp and strlen.

Register names for AKERR_EOF, AKERR_ITERATOR_BREAK and AKERR_NOT_IMPLEMENTED,
which had none and rendered as "Unknown Error" in every stack trace carrying
them. err_error_names.c now sweeps the whole AKERR_* offset span so a code
added without a name fails there instead of in production traces.

Add static assertions that the slot count is a power of two and that
AKERR_BADEXC stays inside the library's own 0-255 band, the latter guarding
against a host errno space large enough to push library codes into the range
consumers are told to allocate from.

Set a project version and soname (1.0.0 / libakerror.so.1) so a stale
installed library can no longer be silently paired with newer headers, and so
akerror.pc ships a real Version field instead of an empty one.

Mutation testing surfaced an out-of-bounds probe in the new table that the
suite did not catch: masking with SLOTS rather than SLOTS-1 indexes past the
array, and err_maxval.c asserted only that some names registered before the
table filled, which a collapsed probe sequence still satisfies. It now
requires a substantial entry count and reads every entry back by its own
distinct name.

Tests: 28/28 pass. Coverage 99.4% line / 86.8% branch. Mutation score for
src/error.c 74% -> 77.3%.

Compatibility: source and ABI break. AKERR_MAX_ERR_VALUE and the
__AKERR_ERROR_NAMES data symbol are gone, custom codes must move out of
0-255, and names must be registered against a reserved range. README.md
carries the migration steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:19:25 -04:00
parent 11d21068df
commit f1283e21a3
11 changed files with 864 additions and 109 deletions

View File

@@ -94,7 +94,7 @@ Re-run after adding tests and confirm the score went up.
## Current status
`src/error.c` scores ~74% (the CI gate is set to 65% for headroom). The
`src/error.c` scores ~77% (the CI gate is set to 65% for headroom). The
remaining survivors are dominated by:
* **Equivalent mutants** in `akerr_init`: deleting the `memset`/`NULL` setup of
@@ -106,7 +106,27 @@ remaining survivors are dominated by:
`errctx == NULL` branch, `exit(1)`): killing these needs a subprocess-based
test that captures a child's stderr and exit code, rather than the in-process
capturing logger the other tests use.
* **Static assertions** (`akerr_assert_name_slots_pow2` and the occupancy cap
it guards): a mutated compile-time assertion that still compiles has no
runtime behavior to observe. Unkillable by construction — the assertion is
itself the test, and `tests/err_maxval.c` covers the runtime consequence.
* **Hash and probe details** in `akerr_status_slot`: dropping one of the
multiply steps in `akerr_status_hash` leaves a worse but still correct hash,
and probing backwards (`slot - 1u`) is an equally valid sequence over a
power-of-two table. Both are behaviorally equivalent.
* **The `capacity <= 0` guard** in `akerr_copy_string`, which is defensive: both
call sites pass a positive constant.
Findings surfaced by mutation testing:
* **Superseded:** status names now use a private sparse registry, so the old public `AKERR_MAX_ERR_VALUE` ceiling and its consumer ABI mismatch no longer exist. `tests/err_maxval.c` covers arbitrary `int` values and registry exhaustion.
* **Superseded:** status names now use a private sparse registry, so the old
public `AKERR_MAX_ERR_VALUE` ceiling and its consumer ABI mismatch no longer
exist. `tests/err_maxval.c` covers arbitrary `int` values and registry
exhaustion.
* **Fixed:** the open-addressing probe mask (`& (AKERR_STATUS_NAME_SLOTS - 1)`)
could be mutated to `- 0` or `+ 1` — both of which index past the end of the
table — without any test noticing. `tests/err_maxval.c` only asserted that
*some* names registered before the table filled, which a collapsed probe
sequence still satisfies. It now requires a substantial number of entries and
reads every one of them back by its own distinct name, so a probe that
revisits slots fails on both counts.

View File

@@ -7,9 +7,12 @@
* Verify the names are actually installed (mutation testing showed the
* registration calls could be deleted without any test noticing).
*
* Note: AKERR_EOF, AKERR_ITERATOR_BREAK and AKERR_NOT_IMPLEMENTED are omitted --
* they are valid codes but akerr_init does not register a display name for them,
* so akerr_name_for_status returns an empty string rather than a known name.
* This list must stay exhaustive. AKERR_EOF, AKERR_ITERATOR_BREAK and
* AKERR_NOT_IMPLEMENTED were previously valid codes with no registered name, so
* they rendered as "Unknown Error" in every stack trace that carried them --
* the same class of silent gap that a too-small AKERR_MAX_ERR_VALUE used to
* cause. The sweep below walks the whole AKERR_* offset span so a newly added
* code without a name fails here rather than showing up in production traces.
*/
static const struct {
@@ -27,7 +30,10 @@ static const struct {
{ AKERR_IO, "Input Output Error" },
{ AKERR_VALUE, "Value Error" },
{ AKERR_RELATIONSHIP, "Relationship Error" },
{ AKERR_EOF, "End Of File" },
{ AKERR_CIRCULAR_REFERENCE, "Circular Reference Error" },
{ AKERR_ITERATOR_BREAK, "Iterator Break" },
{ AKERR_NOT_IMPLEMENTED, "Not Implemented" },
{ AKERR_BADEXC, "Invalid akerr_ErrorContext" },
};
@@ -41,6 +47,28 @@ int main(void)
AKERR_CHECK(strcmp(nm, expected[i].name) == 0);
}
/*
* Every value in the library's own offset span must resolve to a real name.
* AKERR_LAST_ERRNO_VALUE + 7 is the one deliberate hole (a removed code);
* anything else nameless is a code someone added without registering it.
*/
for ( int offset = 1; offset <= AKERR_BADEXC - AKERR_LAST_ERRNO_VALUE; offset++ ) {
int code = AKERR_LAST_ERRNO_VALUE + offset;
char *nm = akerr_name_for_status(code, NULL);
if ( offset == 7 ) {
AKERR_CHECK(strcmp(nm, "Unknown Error") == 0);
continue;
}
if ( strcmp(nm, "Unknown Error") == 0 || nm[0] == '\0' ) {
fprintf(stderr, "AKERR_LAST_ERRNO_VALUE + %d (%d) has no name\n",
offset, code);
return 1;
}
}
/* Every AKERR_* code must sit inside the band the library reserves. */
AKERR_CHECK(AKERR_BADEXC < AKERR_FIRST_CONSUMER_STATUS);
fprintf(stderr, "err_error_names ok\n");
return 0;
}

View File

@@ -3,26 +3,46 @@
#include <limits.h>
#include <string.h>
/* Status magnitude is no longer coupled to a public array bound. */
/*
* Status magnitude is no longer coupled to a public array bound: any int is a
* legal status, and storage is a private sparse registry. What bounds the
* registry now is its *capacity*, not the value of the largest code.
*
* Covers: arbitrary int status values, name truncation, range reservation
* semantics (overlap, idempotency, endpoints, validation, overflow), and both
* capacity limits -- the range table and the name table -- each of which must
* report the failure rather than dropping the registration quietly.
*/
int main(void)
{
akerr_capture_install();
akerr_init();
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MAX, "Maximum Status"),
"Maximum Status") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MIN, "Minimum Status"),
"Minimum Status") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MAX, NULL),
"Maximum Status") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MIN, NULL),
"Minimum Status") == 0);
/* Any int is a legal status, at either extreme of the range. */
AKERR_CHECK(akerr_reserve_status_range(INT_MIN, 1, "min-owner") ==
AKERR_STATUS_RANGE_OK);
AKERR_CHECK(akerr_reserve_status_range(INT_MAX, 1, "max-owner") ==
AKERR_STATUS_RANGE_OK);
AKERR_CHECK(akerr_register_status_name("max-owner", INT_MAX, "Maximum Status") ==
AKERR_STATUS_NAME_OK);
AKERR_CHECK(akerr_register_status_name("min-owner", INT_MIN, "Minimum Status") ==
AKERR_STATUS_NAME_OK);
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MAX, NULL), "Maximum Status") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MIN, NULL), "Minimum Status") == 0);
/* A name longer than the buffer is truncated and always terminated. */
AKERR_CHECK(akerr_reserve_status_range(1000000, 1, "trunc") ==
AKERR_STATUS_RANGE_OK);
const char *long_name =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-extra";
char *stored = akerr_name_for_status(1000000, (char *)long_name);
AKERR_CHECK(akerr_register_status_name("trunc", 1000000, long_name) ==
AKERR_STATUS_NAME_OK);
char *stored = akerr_name_for_status(1000000, NULL);
AKERR_CHECK(strlen(stored) == AKERR_MAX_ERROR_NAME_LENGTH - 1);
AKERR_CHECK(stored[AKERR_MAX_ERROR_NAME_LENGTH - 1] == '\0');
/* Reservation: overlap detection, and idempotency for an exact repeat. */
AKERR_CHECK(akerr_reserve_status_range(256, 16, "component-a") ==
AKERR_STATUS_RANGE_OK);
AKERR_CHECK(akerr_reserve_status_range(256, 16, "component-a") ==
@@ -30,8 +50,15 @@ int main(void)
AKERR_CHECK(akerr_reserve_status_range(260, 2, "component-b") ==
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK_CONTAINS("component-a");
/* The library's own 0..255 band is reserved and cannot be encroached on. */
AKERR_CHECK(akerr_reserve_status_range(255, 1, "component-b") ==
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK(akerr_reserve_status_range(0, AKERR_RESERVED_STATUS_COUNT,
AKERR_LIBRARY_OWNER) ==
AKERR_STATUS_RANGE_OK); /* exact repeat by the owner */
/* Argument validation. */
AKERR_CHECK(akerr_reserve_status_range(INT_MAX, 2, "overflow") ==
AKERR_STATUS_RANGE_INVALID);
AKERR_CHECK(akerr_reserve_status_range(300, 0, "empty") ==
@@ -43,6 +70,7 @@ int main(void)
AKERR_CHECK(akerr_reserve_status_range(300, 1, "") ==
AKERR_STATUS_RANGE_INVALID);
/* Owner strings: 63 chars fit, 64 do not. */
char owner63[AKERR_MAX_ERROR_NAME_LENGTH];
char owner64[AKERR_MAX_ERROR_NAME_LENGTH + 1];
memset(owner63, 'a', sizeof(owner63) - 1);
@@ -53,37 +81,79 @@ int main(void)
AKERR_STATUS_RANGE_OK);
AKERR_CHECK(akerr_reserve_status_range(401, 1, owner64) ==
AKERR_STATUS_RANGE_INVALID);
AKERR_CHECK(akerr_reserve_status_range(INT_MAX, 1, "int-max") ==
AKERR_STATUS_RANGE_OK);
/* Partial overlaps at either endpoint, and a same-range different owner. */
AKERR_CHECK(akerr_reserve_status_range(500, 2, "endpoint") ==
AKERR_STATUS_RANGE_OK);
AKERR_CHECK(akerr_reserve_status_range(499, 2, "left") ==
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK(akerr_reserve_status_range(500, 1, "endpoint") ==
AKERR_STATUS_RANGE_OVERLAP);
AKERR_STATUS_RANGE_OVERLAP); /* subset, not an exact repeat */
AKERR_CHECK(akerr_reserve_status_range(501, 1, "endpoint") ==
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK(akerr_reserve_status_range(500, 2, "other") ==
AKERR_STATUS_RANGE_OVERLAP);
AKERR_STATUS_RANGE_OVERLAP); /* same range, wrong owner */
/* Five ranges exist: library, component-a, owner63, INT_MAX, endpoint. */
for ( int i = 0; i < 59; i++ ) {
AKERR_CHECK(akerr_reserve_status_range(1000 + (i * 2), 1, "fill") ==
AKERR_STATUS_RANGE_OK);
/* Claim room for the name-exhaustion sweep before filling the range table. */
AKERR_CHECK(akerr_reserve_status_range(2000000, 100000, "fill") ==
AKERR_STATUS_RANGE_OK);
/*
* Range table capacity. The limit is private to src/error.c on purpose, so
* discover it by filling rather than by hardcoding it here.
*/
akerr_capture_reset();
int ranges_added = 0;
int range_rc = AKERR_STATUS_RANGE_OK;
for ( int i = 0; i < 100000; i++ ) {
range_rc = akerr_reserve_status_range(1000 + (i * 2), 1, "pad");
if ( range_rc != AKERR_STATUS_RANGE_OK ) {
break;
}
ranges_added++;
}
AKERR_CHECK(akerr_reserve_status_range(2000, 1, "too-many") ==
AKERR_STATUS_RANGE_FULL);
AKERR_CHECK(ranges_added > 0);
AKERR_CHECK(range_rc == AKERR_STATUS_RANGE_FULL);
AKERR_CHECK_CONTAINS("range table is full");
int filled = 0;
for ( int status = 2000000; status < 2000600; status++ ) {
if ( strcmp(akerr_name_for_status(status, "Filled"), "Filled") != 0 ) {
filled = 1;
/*
* Name table capacity. Exhaustion must be reported, not silent: a dropped
* name degrades every future stack trace for that code to "Unknown Error".
*/
akerr_capture_reset();
int full_at = -1;
for ( int i = 0; i < 100000; i++ ) {
char name[32];
snprintf(name, sizeof(name), "Filled %d", i);
int rc = akerr_register_status_name("fill", 2000000 + i, name);
if ( rc != AKERR_STATUS_NAME_OK ) {
AKERR_CHECK(rc == AKERR_STATUS_NAME_FULL);
full_at = i;
break;
}
}
AKERR_CHECK(filled == 1);
AKERR_CHECK_CONTAINS("registry is full");
AKERR_CHECK_CONTAINS("AKERR_STATUS_NAME_SLOTS");
fprintf(stderr, "err_maxval ok\n");
/*
* The table must actually hold everything it accepted. A probe sequence
* that revisits slots instead of walking the table -- e.g. masking with
* SLOTS rather than SLOTS-1 -- both collapses the usable capacity and
* loses earlier entries, and each check below catches it independently.
* The floor assumes at least the default table size (4096 slots).
*/
AKERR_CHECK(full_at > 256);
for ( int i = 0; i < full_at; i++ ) {
char expected[32];
snprintf(expected, sizeof(expected), "Filled %d", i);
AKERR_CHECK(strcmp(akerr_name_for_status(2000000 + i, NULL), expected) == 0);
}
/* A dropped name reads back as the sentinel, and earlier ones survive. */
AKERR_CHECK(strcmp(akerr_name_for_status(2000000 + full_at, NULL),
"Unknown Error") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MIN, NULL), "Minimum Status") == 0);
fprintf(stderr, "err_maxval ok (%d consumer names before full)\n", full_at);
return 0;
}

View File

@@ -0,0 +1,97 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* Reserving a range used to be pure bookkeeping: akerr_name_for_status() would
* name any status for any caller, so two components could still register names
* for the same code -- and HANDLE the same code -- with nothing detecting it.
* Reservation only caught components that both opted in AND declared ranges
* that happened to overlap.
*
* Naming a status is now permitted only inside a reservation:
* - akerr_register_status_name() requires the range to belong to the caller;
* - the legacy two-argument akerr_name_for_status() set path cannot identify
* its caller, so it can only require that *some* reservation covers the
* status -- still enough to stop a code nobody claimed.
* Every refusal is logged, because a name that fails to register degrades the
* status to "Unknown Error" in every later stack trace.
*/
int main(void)
{
akerr_capture_install();
akerr_init();
AKERR_CHECK(akerr_reserve_status_range(256, 16, "lib-a") ==
AKERR_STATUS_RANGE_OK);
AKERR_CHECK(akerr_reserve_status_range(512, 16, "lib-b") ==
AKERR_STATUS_RANGE_OK);
/* The owner of a range may name statuses inside it. */
AKERR_CHECK(akerr_register_status_name("lib-a", 256, "A Parse Error") ==
AKERR_STATUS_NAME_OK);
AKERR_CHECK(akerr_register_status_name("lib-a", 271, "A Last Error") ==
AKERR_STATUS_NAME_OK);
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "A Parse Error") == 0);
/* Naming another owner's status is refused and names the real owner. */
akerr_capture_reset();
AKERR_CHECK(akerr_register_status_name("lib-b", 256, "B Hijack") ==
AKERR_STATUS_NAME_FOREIGN);
AKERR_CHECK_CONTAINS("lib-a");
AKERR_CHECK_CONTAINS("lib-b");
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "A Parse Error") == 0);
/* Including the library's own reserved band. */
akerr_capture_reset();
AKERR_CHECK(akerr_register_status_name("lib-b", AKERR_VALUE, "B Value") ==
AKERR_STATUS_NAME_FOREIGN);
AKERR_CHECK_CONTAINS(AKERR_LIBRARY_OWNER);
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_VALUE, NULL), "Value Error") == 0);
/* A status nobody reserved cannot be named through either entry point. */
akerr_capture_reset();
AKERR_CHECK(akerr_register_status_name("lib-a", 9999, "Unclaimed") ==
AKERR_STATUS_NAME_UNRESERVED);
AKERR_CHECK_CONTAINS("no reserved range");
AKERR_CHECK(strcmp(akerr_name_for_status(9999, NULL), "Unknown Error") == 0);
akerr_capture_reset();
AKERR_CHECK(strcmp(akerr_name_for_status(9999, "Unclaimed Legacy"),
"Unknown Error") == 0);
AKERR_CHECK_CONTAINS("no reserved range");
AKERR_CHECK(strcmp(akerr_name_for_status(9999, NULL), "Unknown Error") == 0);
/* Boundaries: just outside lib-a's range is not lib-a's to name. */
AKERR_CHECK(akerr_register_status_name("lib-a", 255, "Below") ==
AKERR_STATUS_NAME_FOREIGN);
AKERR_CHECK(akerr_register_status_name("lib-a", 272, "Above") ==
AKERR_STATUS_NAME_UNRESERVED);
/* The legacy set path still works inside any reservation. */
AKERR_CHECK(strcmp(akerr_name_for_status(513, "B Legacy"), "B Legacy") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(513, NULL), "B Legacy") == 0);
/* Re-registering your own status overwrites the name. */
AKERR_CHECK(akerr_register_status_name("lib-a", 256, "A Renamed") ==
AKERR_STATUS_NAME_OK);
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "A Renamed") == 0);
/* Argument validation. */
AKERR_CHECK(akerr_register_status_name(NULL, 257, "No Owner") ==
AKERR_STATUS_NAME_INVALID);
AKERR_CHECK(akerr_register_status_name("", 257, "Empty Owner") ==
AKERR_STATUS_NAME_INVALID);
AKERR_CHECK(akerr_register_status_name("lib-a", 257, NULL) ==
AKERR_STATUS_NAME_INVALID);
/* A refused registration must not consume a slot or leave a partial entry. */
AKERR_CHECK(strcmp(akerr_name_for_status(257, NULL), "Unknown Error") == 0);
/* Lookup is unaffected by ownership -- anyone may read any name. */
AKERR_CHECK(strcmp(akerr_name_for_status(271, NULL), "A Last Error") == 0);
fprintf(stderr, "err_name_ownership ok\n");
return 0;
}

View File

@@ -0,0 +1,56 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* A library may reserve its status range from its own init() before anything in
* the process has raised an error, i.e. before akerr_init() has run. That used
* to be silently destructive: akerr_init() clears the range and name tables, so
* whichever component first triggered it (via PREPARE_ERROR) wiped the earlier
* reservation, and the *next* component to claim the same range was told OK --
* producing exactly the undetected aliasing the registry exists to prevent.
*
* Every public registry entry point now calls akerr_init() itself, so the
* tables are only ever cleared before the first reservation, never after one.
*
* Note this test must not call akerr_init() or PREPARE_ERROR first -- the
* uninitialized entry is the whole point.
*/
int main(void)
{
akerr_capture_install();
/* Cold call: no akerr_init(), no PREPARE_ERROR anywhere yet. */
AKERR_CHECK(akerr_reserve_status_range(256, 16, "early-lib") ==
AKERR_STATUS_RANGE_OK);
AKERR_CHECK(akerr_register_status_name("early-lib", 256, "Early Error") ==
AKERR_STATUS_NAME_OK);
/* Something else now uses the library for the first time. */
akerr_init();
PREPARE_ERROR(e);
(void)e;
/* The early reservation and its name must both have survived. */
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "Early Error") == 0);
AKERR_CHECK(akerr_reserve_status_range(256, 16, "late-lib") ==
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK_CONTAINS("early-lib");
AKERR_CHECK(akerr_reserve_status_range(260, 2, "late-lib") ==
AKERR_STATUS_RANGE_OVERLAP);
/* The library's own initialization still happened exactly once. */
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_NULLPOINTER, NULL),
"Null Pointer Error") == 0);
AKERR_CHECK(akerr_reserve_status_range(0, AKERR_RESERVED_STATUS_COUNT,
AKERR_LIBRARY_OWNER) ==
AKERR_STATUS_RANGE_OK);
/* An identical repeat by the original owner is still idempotent. */
AKERR_CHECK(akerr_reserve_status_range(256, 16, "early-lib") ==
AKERR_STATUS_RANGE_OK);
fprintf(stderr, "err_registry_init_order ok\n");
return 0;
}