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

@@ -1,19 +1,17 @@
/*
* aksl_realpath -- TODO.md section 1.5.
* aksl_realpath and aksl_realpath_alloc -- TODO.md section 1.5, now complete.
*
* The happy paths compare against realpath(3) itself rather than against a
* hard-coded string, because $TMPDIR may itself be a symlink (/tmp -> /private/tmp
* and friends) and the resolved answer is what the platform says it is.
*
* Every failure case here passes a *zeroed* resolved_path buffer. That is
* deliberate: on failure the wrapper formats resolved_path with %s while
* realpath(3) leaves the buffer unspecified (TODO.md 2.1.6), so a test that
* passed an uninitialised buffer would be reading uninitialised memory in the
* library's own error path. The uninitialised-buffer crash is the defect's own
* test to write, not something these should trip over incidentally.
*
* Also not covered: resolved_path == NULL, which is unchecked today and leaks
* the buffer realpath(3) allocates (2.1.6).
* The failure cases now pass an *uninitialised* resolved_path on purpose. That
* used to be the crash case (TODO.md 2.1.6): the wrapper's own error path
* formatted the buffer with %s while realpath(3) leaves its contents
* unspecified on failure, so the library read uninitialised memory while
* reporting an error. The message names only the input path now, and this test
* is what holds that -- it is meant to be run under the sanitizer build, where a
* regression is an immediate abort rather than a silent read of stack garbage.
*/
#include "aksl_capture.h"
@@ -31,7 +29,7 @@ static int test_resolves_an_existing_file(void)
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK(realpath(path, expected) != NULL);
AKSL_CHECK_OK(aksl_realpath(path, resolved));
AKSL_CHECK_OK(aksl_realpath(path, resolved, sizeof(resolved)));
AKSL_CHECK(strcmp(resolved, expected) == 0);
AKSL_CHECK(resolved[0] == '/');
AKSL_CHECK(unlink(path) == 0);
@@ -54,7 +52,7 @@ static int test_resolves_a_symlink_to_its_target(void)
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK(realpath(target, expected) != NULL);
AKSL_CHECK_OK(aksl_realpath(link, resolved));
AKSL_CHECK_OK(aksl_realpath(link, resolved, sizeof(resolved)));
AKSL_CHECK(strcmp(resolved, expected) == 0);
AKSL_CHECK(unlink(link) == 0);
@@ -62,14 +60,20 @@ static int test_resolves_a_symlink_to_its_target(void)
return 0;
}
/*
* The failure path with a buffer nobody has written to. Uninitialised on
* purpose: see the header comment. Under ASan/MSan this is the test that fails
* if the error path ever starts reading resolved_path again.
*/
static int test_missing_path_reports_enoent(void)
{
char resolved[PATH_MAX];
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_realpath("/nonexistent/aksl/path", resolved),
aksl_realpath("/nonexistent/aksl/path", resolved, sizeof(resolved)),
ENOENT, "/nonexistent/aksl/path");
/* The message must name the path and must not quote the buffer back. */
AKSL_CHECK(strstr(aksl_last_message, "resolved") == NULL);
return 0;
}
@@ -84,19 +88,94 @@ static int test_non_directory_component_reports_enotdir(void)
AKSL_CHECK((size_t)snprintf(child, sizeof(child), "%s/child", path)
< sizeof(child));
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK_STATUS(aksl_realpath(child, resolved), ENOTDIR);
AKSL_CHECK_STATUS(aksl_realpath(child, resolved, sizeof(resolved)), ENOTDIR);
AKSL_CHECK(unlink(path) == 0);
return 0;
}
static int test_rejects_null_path(void)
/* Two symlinks pointing at each other: the kernel gives up with ELOOP. */
static int test_symlink_loop_reports_eloop(void)
{
char a[AKSL_TMP_MAX];
char b[AKSL_TMP_MAX];
char resolved[PATH_MAX];
AKSL_CHECK(aksl_temp_file(a, sizeof(a)) == 0);
AKSL_CHECK(aksl_temp_file(b, sizeof(b)) == 0);
AKSL_CHECK(unlink(a) == 0);
AKSL_CHECK(unlink(b) == 0);
AKSL_CHECK(symlink(a, b) == 0);
AKSL_CHECK(symlink(b, a) == 0);
AKSL_CHECK_STATUS(aksl_realpath(a, resolved, sizeof(resolved)), ELOOP);
AKSL_CHECK(unlink(a) == 0);
AKSL_CHECK(unlink(b) == 0);
return 0;
}
static int test_rejects_null_arguments(void)
{
char resolved[PATH_MAX];
memset(resolved, 0x00, sizeof(resolved));
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_realpath(NULL, resolved),
AKERR_NULLPOINTER, "path=");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_realpath(NULL, resolved, sizeof(resolved)),
AKERR_NULLPOINTER, "path=");
/*
* TODO.md 2.1.6: this used to be unchecked, and realpath(path, NULL)
* allocated a buffer that the wrapper then discarded and leaked.
*/
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_realpath("/tmp", NULL, PATH_MAX),
AKERR_NULLPOINTER, "resolved_path=");
return 0;
}
/*
* realpath(3) cannot be told how much room it has, so the only safe answer to an
* undersized buffer is to refuse before calling it. A caller who gets this back
* has a bug that would otherwise have been a stack smash.
*/
static int test_rejects_a_buffer_below_path_max(void)
{
char small[16];
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_realpath("/tmp", small, sizeof(small)),
AKERR_OUTOFBOUNDS, "PATH_MAX");
return 0;
}
static int test_alloc_form_resolves_and_hands_over_the_buffer(void)
{
char path[AKSL_TMP_MAX];
char expected[PATH_MAX];
char *resolved = NULL;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
AKSL_CHECK(realpath(path, expected) != NULL);
AKSL_CHECK_OK(aksl_realpath_alloc(path, &resolved));
AKSL_CHECK(resolved != NULL);
AKSL_CHECK(strcmp(resolved, expected) == 0);
/* The buffer is the caller's; releasing it through the library closes the
* leak that the old NULL-destination path opened. */
AKSL_CHECK_OK(aksl_free(resolved));
AKSL_CHECK(unlink(path) == 0);
return 0;
}
static int test_alloc_form_reports_failure_and_writes_no_pointer(void)
{
char *resolved = (char *)0x1;
AKSL_CHECK_STATUS_MSG_CONTAINS(
aksl_realpath_alloc("/nonexistent/aksl/path", &resolved),
ENOENT, "/nonexistent/aksl/path");
/* Cleared before the call, so a failure cannot leave a stale pointer. */
AKSL_CHECK(resolved == NULL);
AKSL_CHECK_STATUS(aksl_realpath_alloc(NULL, &resolved), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_realpath_alloc("/tmp", NULL), AKERR_NULLPOINTER);
return 0;
}
@@ -110,7 +189,11 @@ int main(void)
AKSL_RUN(failures, test_resolves_a_symlink_to_its_target);
AKSL_RUN(failures, test_missing_path_reports_enoent);
AKSL_RUN(failures, test_non_directory_component_reports_enotdir);
AKSL_RUN(failures, test_rejects_null_path);
AKSL_RUN(failures, test_symlink_loop_reports_eloop);
AKSL_RUN(failures, test_rejects_null_arguments);
AKSL_RUN(failures, test_rejects_a_buffer_below_path_max);
AKSL_RUN(failures, test_alloc_form_resolves_and_hands_over_the_buffer);
AKSL_RUN(failures, test_alloc_form_reports_failure_and_writes_no_pointer);
AKSL_REPORT(failures);
}