Fix the six confirmed defects and close the API contract gaps

TODO.md section 2.1 recorded six defects reproduced against the built
library, and section 2.2 seventeen contract gaps. Both are closed. The
four tests registered in AKSL_KNOWN_FAILING_TESTS are folded back into
the tests for the things they test, and that list is now empty.

The defects:

  2.1.1  aksl_list_append conflated Floyd cycle detection with finding
         the tail, so `tail` tracked the node behind the midpoint. Any
         append to a list of 2+ nodes silently dropped everything after
         it. Two separate walks now: Floyd to prove the list is finite,
         then a plain walk to the end.
  2.1.2  aksl_list_iterate started visiting from Floyd's `slow` cursor,
         so the whole first half of the list -- head included -- was
         never passed to the callback. It starts at the head.
  2.1.3  AKERR_ITERATOR_BREAK did not stop a tree traversal: the frame
         that raised it handled it and returned success, so the parent
         carried on into the sibling subtree. The recursion is split out
         and propagates the break; only the public entry swallows it.
  2.1.4  va_end now matches every va_start on every path.
  2.1.5  The ato* family had no error channel at all. Reimplemented over
         a new strto* family with errno cleared, an endptr check and a
         range check: AKERR_VALUE for junk, ERANGE for overflow.
  2.1.6  aksl_realpath never checked resolved_path, could not be told
         the buffer size, and formatted an unspecified buffer with %s on
         its own error path. It takes a length; aksl_realpath_alloc is
         the allocating form.

The contract gaps, in brief: errno is cleared before every wrapped call
and read back through a fallback so no error can carry status 0; fopen
validates pathname and mode; fread/fwrite report the transferred count
through a required out-param and no longer call a short transfer a
success; aksl_sprintf is gone in favour of aksl_snprintf, which treats
truncation as an error; the variadic wrappers carry format attributes;
djb2 reads bytes as unsigned; tree traversal is depth- and cycle-bounded
and implements BFS, so lalloc/lfree are used rather than merely stored;
an unknown searchmode is AKERR_VALUE rather than silent success;
list_pop takes the head by reference; aksl_freep, the node initialisers
and extern "C" are new.

Build: -pg is out of the default build (it never reached the C compiler
anyway, and it is what produced the stray gmon.out), -Wall -Wextra are
in, and there is a .gitignore.

Tests: 11 binaries, all green under the normal and sanitizer builds.
Visit-order assertions replace the step counts that could not tell the
three depth-first orders apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 07:14:11 -04:00
parent fd71bcc67b
commit 55eb0334c4
17 changed files with 3272 additions and 730 deletions

View File

@@ -5,9 +5,9 @@
* h = h * 33 + byte for each of len bytes, truncated to 32 bits. They were
* computed independently of this implementation.
*
* Every vector here is 7-bit ASCII, where signed and unsigned char agree. The
* high-bit case ("\xff\xfe") is the sign-extension defect in TODO.md 2.2.6 and
* is left for a test that can be registered as a known failure.
* Most vectors here are 7-bit ASCII, where signed and unsigned char agree and
* the test therefore says nothing either way about byte signedness. The high-bit
* vector is the one that pins TODO.md 2.2.6 down.
*/
#include "aksl_capture.h"
@@ -46,6 +46,49 @@ static int test_known_answer_vectors(void)
return 0;
}
/*
* TODO.md 2.2.6, pinned. The cursor is an unsigned char * now, so bytes at or
* above 0x80 contribute their unsigned value. Iterating a plain char * on x86 or
* ARM Linux made them negative, giving 5859874 here instead of 5868578 -- a hash
* that disagreed with canonical djb2 and, worse, disagreed with itself across
* platforms depending on whether char happened to be signed.
*
* Benign for the 7-bit identifiers akbasic hashes; quietly wrong for the first
* caller to key a table on a filename or a UTF-8 string literal.
*/
static int test_high_bit_bytes_are_unsigned(void)
{
char buf[2] = { (char)0xff, (char)0xfe };
uint32_t h = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf), &h));
AKSL_CHECK(h == 5868578u);
/* The sign-extended answer, spelled out so a regression names itself. */
AKSL_CHECK(h != 5859874u);
return 0;
}
/* The NUL-terminated convenience form (TODO.md 3.6) agrees with the length one. */
static int test_str_form_matches_the_length_form(void)
{
const char *s = "libakstdlib";
uint32_t from_str = 0;
uint32_t from_len = 0;
AKSL_CHECK_OK(aksl_strhash_djb2_str(s, &from_str));
AKSL_CHECK_OK(aksl_strhash_djb2(s, strlen(s), &from_len));
AKSL_CHECK(from_str == from_len);
AKSL_CHECK(from_str == 884285482u);
/* The empty string is the seed, not an error. */
AKSL_CHECK_OK(aksl_strhash_djb2_str("", &from_str));
AKSL_CHECK(from_str == 5381);
AKSL_CHECK_STATUS(aksl_strhash_djb2_str(NULL, &from_str), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_strhash_djb2_str("x", NULL), AKERR_NULLPOINTER);
return 0;
}
/* The function is length-driven, not NUL-driven: an embedded NUL is hashed. */
static int test_embedded_nul_is_hashed(void)
{
@@ -95,6 +138,8 @@ int main(void)
AKSL_RUN(failures, test_empty_string_is_the_djb2_seed);
AKSL_RUN(failures, test_zero_length_ignores_the_buffer);
AKSL_RUN(failures, test_known_answer_vectors);
AKSL_RUN(failures, test_high_bit_bytes_are_unsigned);
AKSL_RUN(failures, test_str_form_matches_the_length_form);
AKSL_RUN(failures, test_embedded_nul_is_hashed);
AKSL_RUN(failures, test_hash_is_stable_across_calls);
AKSL_RUN(failures, test_rejects_null_arguments);