diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8a68808..5413c69 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -9,6 +9,10 @@ jobs: - run: echo "Triggered by ${{ gitea.event_name }} from ${{ gitea.repository }}@${{ gitea.ref }}. Building on ${{ runner.os }}." - name: Check out repository code uses: actions/checkout@v4 + with: + # A top-level build uses deps/libakerror via add_subdirectory, so the + # submodule has to be present or the configure step fails outright. + submodules: recursive - name: dependencies run: | sudo apt-get update -y @@ -24,5 +28,5 @@ jobs: cmake -S . -B build cmake --build build sudo cmake --install build - cmake --build build --target test + ctest --test-dir build --output-on-failure - run: echo "🍏 This job's status is ${{ job.status }}." diff --git a/CMakeLists.txt b/CMakeLists.txt index e22a829..3b9d8a9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,21 @@ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -ggdb -pg") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -g -ggdb -pg") set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -g -ggdb -pg") +# Sanitizer build, off by default: +# cmake -S . -B build-asan -DAKSL_SANITIZE=ON && ctest --test-dir build-asan +# Set before the dependency is added so libakerror is instrumented too -- +# several of the defects in TODO.md section 2 (the uninitialised %s in +# aksl_realpath, the unbounded vsprintf in aksl_sprintf, the missing va_end in +# the printf family) only show up under ASan/UBSan. +option(AKSL_SANITIZE "Build the library and its tests with ASan + UBSan" OFF) +if(AKSL_SANITIZE) + set(AKSL_SANITIZE_FLAGS "-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${AKSL_SANITIZE_FLAGS}") + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${AKSL_SANITIZE_FLAGS}") + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} ${AKSL_SANITIZE_FLAGS}") + message(STATUS "AKSL_SANITIZE=ON: building with ASan + UBSan") +endif() + if(TARGET akerror::akerror) message(STATUS "FOUND akerror::akerror") else() @@ -16,7 +31,30 @@ include(GNUInstallDirs) include(CMakePackageConfigHelpers) if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + # libakerror registers its own CTest entries unconditionally, and because we + # pull it in EXCLUDE_FROM_ALL its test binaries are never built -- so every + # one of them used to show up in our suite as "Not Run" and fail. CMake has no + # way to un-register a test, and set_tests_properties cannot reach across + # directory scopes, so add_test() is shadowed for the duration of the + # add_subdirectory() call. The dependency has its own CI; this project's suite + # should only contain this project's tests. + set(AKSL_SUPPRESS_ADD_TEST TRUE) + function(add_test) + if(NOT AKSL_SUPPRESS_ADD_TEST) + _add_test(${ARGV}) + endif() + endfunction() + # libakerror also marks two of those tests WILL_FAIL, which would now be + # setting properties on tests that no longer exist, so suppress that too. + function(set_tests_properties) + if(NOT AKSL_SUPPRESS_ADD_TEST) + _set_tests_properties(${ARGV}) + endif() + endfunction() + add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL) + + set(AKSL_SUPPRESS_ADD_TEST FALSE) else() if(NOT TARGET akerror::akerror) find_package(PkgConfig REQUIRED) @@ -75,13 +113,66 @@ install(FILES DESTINATION ${akstdlib_install_cmakedir} ) -add_executable(test_linkedlist tests/test_linkedlist.c) -target_link_libraries(test_linkedlist PRIVATE akstdlib) -add_test(NAME linkedlist COMMAND test_linkedlist) +# Each test is one source file tests/test_.c, built into the executable +# test_ and registered as the CTest test . Shared helpers live in +# tests/aksl_capture.h. +# +# AKSL_TESTS must exit 0. +# AKSL_WILL_FAIL_TESTS expected to abort by design (an unhandled error +# reaching FINISH_NORETURN, a deliberate contract +# violation), so a non-zero exit is a pass. +# AKSL_KNOWN_FAILING_TESTS assert the *correct* behaviour of a confirmed +# defect from TODO.md section 2.1. They fail until +# the defect is fixed, and are marked WILL_FAIL so +# the suite stays green and the gap stays visible. +# When one is fixed CTest reports it as failed with +# "unexpectedly passed" -- that is the cue to move +# it up into AKSL_TESTS. +set(AKSL_TESTS + linkedlist + tree +) -add_executable(test_tree tests/test_tree.c) -target_link_libraries(test_tree PRIVATE akstdlib) -add_test(NAME tree COMMAND test_tree) +set(AKSL_WILL_FAIL_TESTS +) + +set(AKSL_KNOWN_FAILING_TESTS + list_append_chain # TODO.md 2.1.1 -- append truncates lists of 2+ nodes + list_iterate_head # TODO.md 2.1.2 -- iterate starts at the midpoint + tree_iterate_break # TODO.md 2.1.3 -- ITERATOR_BREAK does not stop a walk +) + +foreach(_test IN LISTS AKSL_TESTS AKSL_WILL_FAIL_TESTS AKSL_KNOWN_FAILING_TESTS) + add_executable(test_${_test} tests/test_${_test}.c) + target_include_directories(test_${_test} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests) + target_link_libraries(test_${_test} PRIVATE akstdlib) + add_test(NAME ${_test} COMMAND test_${_test}) +endforeach() + +if(AKSL_WILL_FAIL_TESTS OR AKSL_KNOWN_FAILING_TESTS) + set_tests_properties( + ${AKSL_WILL_FAIL_TESTS} ${AKSL_KNOWN_FAILING_TESTS} + PROPERTIES WILL_FAIL TRUE + ) +endif() + +# Mutation testing: break the library in small ways and confirm the test suite +# notices. This is a meta-check on the tests themselves, so it is a manual +# target (it rebuilds and re-runs the whole suite once per mutant), not a CTest +# test and not yet a CI gate -- the per-function tests in TODO.md sections +# 1.1-1.9 need to exist before a threshold would mean anything. +# cmake --build build --target mutation +find_package(Python3 COMPONENTS Interpreter) +if(Python3_FOUND) + add_custom_target(mutation + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py + --source-root ${CMAKE_CURRENT_SOURCE_DIR} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + USES_TERMINAL + COMMENT "Running mutation tests (breaks the library, expects tests to fail)" + ) +endif() # pkgconfig diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..fed814c --- /dev/null +++ b/TODO.md @@ -0,0 +1,531 @@ +# TODO + +Working notes for `libakstdlib` β€” the akerror-wrapped libc surface. + +Scope of the current library (`include/akstdlib.h`, `src/stdlib.c`): 16 libc wrappers +(`fopen`, `fread`, `fwrite`, `fclose`, `malloc`, `free`, `memset`, `memcpy`, `printf`, +`fprintf`, `sprintf`, `atoi`, `atol`, `atoll`, `atof`, `realpath`), one hash helper +(`aksl_strhash_djb2`), a doubly-linked list (`append`/`pop`/`iterate`) and a binary tree +iterator (`aksl_tree_iterate`). + +Items marked **[CONFIRMED]** were reproduced by building the library and running a probe +program against it, not just read off the source. + +--- + +## 1. Unit tests that should be written + +### 1.0 Test-harness prerequisites β€” **DONE** (branch `feature/test-harness`) + +`ctest` is green: 5/5, no *Not Run* entries. Everything in Β§1.1–1.9 can now be written +against `tests/aksl_capture.h` and added to the `AKSL_TESTS` list. + +- [x] **The current tests cannot fail.** `tests/test_linkedlist.c` asserted nothing at all; + it printed node names and returned `0` unconditionally, so any of the list bugs in + Β§2.1 would pass it. Rewritten as 15 assertion-based cases covering the append, iterate + and pop behaviour that is correct today. +- [x] **`tests/test_tree.c` was red.** `ctest` reported `tree (Failed)`, exit 136, because + `parms.steps` was never reset between the three searches, so the second assertion + compared an accumulated `14` against `7`. Each search now builds its own tree and + params. The file records that its step counts do not actually distinguish the three + traversal orders β€” real order assertions remain Β§1.8. +- [x] **Adopt libakerror's test conventions.** `tests/aksl_capture.h` provides `AKSL_CHECK()` + (an `NDEBUG`-proof assert), `AKSL_CHECK_STATUS()`/`AKSL_CHECK_OK()` which run an + akerror-returning call, assert on its status and release the context, + `AKSL_CHECK_MSG_CONTAINS()`, a capturing `akerr_log_method`, `aksl_slots_in_use()` for + pool-leak checks, and an `AKSL_RUN()` driver that additionally fails any test which + leaks an error-pool slot. +- [x] **One test source per behaviour, registered with CTest via a list variable.** + `tests/test_.c` β†’ executable `test_` β†’ CTest test ``, driven by + `AKSL_TESTS` in `CMakeLists.txt`. +- [x] **Add a `WILL_FAIL` category.** Two lists: `AKSL_WILL_FAIL_TESTS` for tests that abort + by design (empty for now) and `AKSL_KNOWN_FAILING_TESTS` for the confirmed defects in + Β§2.1 β€” see the note at the head of Β§2.1. +- [x] **Fix the four permanently-failing submodule tests.** `deps/libakerror` is added + `EXCLUDE_FROM_ALL`, so akerror's `add_test` entries registered but its test binaries + were never built, and `ctest` reported `err_catch`, `err_cleanup`, `err_trace`, + `err_improper_closure` as *Not Run* β†’ failed on every run. Neither the pinned commit + nor upstream `main` guards that registration, CMake has no way to un-register a test, + and `set_tests_properties` cannot reach across directory scopes β€” so `add_test` and + `set_tests_properties` are shadowed for the duration of the `add_subdirectory` call. + The dependency has its own CI; this suite now contains only this project's tests. +- [x] **Wire in a memory-error build.** `cmake -S . -B build-asan -DAKSL_SANITIZE=ON` adds + `-fsanitize=address,undefined -fno-sanitize-recover=all` to the library, the tests and + the vendored libakerror. Currently clean, and it is the intended way to test the items + in Β§2 that only misbehave under instrumentation (uninitialised `%s` in + `aksl_realpath`, unbounded `vsprintf`, missing `va_end`). +- [x] **Port `scripts/mutation_test.py` from libakerror.** Retargeted at `src/stdlib.c` and + `include/akstdlib.h` (178 mutants) and exposed as + `cmake --build build --target mutation`. Deliberately *not* a CI gate yet, and no + `--threshold` is set: a mutation score only means something once Β§1.1–1.9 exist, and + against today's tests it would do little but confirm that most of the library is + untested. +- [x] **CI could not have run any of this.** `actions/checkout@v4` was not fetching + submodules, so `add_subdirectory(deps/libakerror)` had nothing to descend into and the + configure step failed before ever reaching the tests. Added `submodules: recursive`, + and swapped `cmake --build build --target test` for `ctest --output-on-failure` so a + failure is diagnosable from the job log. The deeper problem β€” CI installing + `libakerror@main` while the build actually compiles the pinned submodule β€” is Β§2.3. + +### 1.1 Memory wrappers + +- [ ] `aksl_malloc` β€” happy path returns non-NULL and writes through `dst`. +- [ ] `aksl_malloc(n, NULL)` β†’ `AKERR_NULLPOINTER`, message content asserted. +- [ ] `aksl_malloc(0, &p)` β€” pin down the contract. Today the result depends on whether the + platform's `malloc(0)` returns NULL; if it does, the wrapper reports `errno`, which may + be `0`. See Β§2.2.1. +- [ ] `aksl_malloc(SIZE_MAX, &p)` β†’ allocation failure surfaces `ENOMEM`, and `*dst` is left + NULL rather than garbage. +- [ ] `aksl_free(NULL)` β†’ `AKERR_NULLPOINTER` (assert the documented behaviour, since + `free(NULL)` is legal C and callers will be surprised). +- [ ] `aksl_free` happy path, and a mallocβ†’free round trip under ASan. +- [ ] `aksl_memset` β€” happy path fills the buffer; `n == 0` is a no-op; `s == NULL` β†’ + `AKERR_NULLPOINTER`. +- [ ] `aksl_memcpy` β€” happy path copies; `d == NULL` and `s == NULL` each β†’ + `AKERR_NULLPOINTER`; `n == 0` with valid pointers succeeds. +- [ ] `aksl_memcpy` with overlapping regions β€” decide and test whether this is rejected + (`AKERR_VALUE`) or documented as UB like `memcpy`. + +### 1.2 File I/O + +- [ ] `aksl_fopen` happy path on a temp file; `*fp` is written. +- [ ] `aksl_fopen("/nonexistent/path", "r", &fp)` β†’ `ENOENT` propagated as the status, with + the pathname in the message. +- [ ] `aksl_fopen` on a mode-denied path (e.g. `/proc/1/mem`, or a `chmod 000` temp file) β†’ + `EACCES`. +- [ ] `aksl_fopen(path, mode, NULL)` β†’ `AKERR_NULLPOINTER`. +- [ ] `aksl_fopen(NULL, "r", &fp)` and `aksl_fopen(path, NULL, &fp)` β†’ currently unchecked, + see Β§2.2.2. Test once the guards exist. +- [ ] `aksl_fread` full read; short read at EOF β†’ `AKERR_EOF`; read from a write-only stream + β†’ `AKERR_IO`; `fp == NULL` β†’ `AKERR_NULLPOINTER`; `ptr == NULL` β†’ should be + `AKERR_NULLPOINTER` (see Β§2.2.3). +- [ ] `aksl_fread` **partial read that is neither EOF nor error** β€” assert whatever the fixed + contract is; today this silently returns success and the caller cannot tell how many + members were read. +- [ ] `aksl_fwrite` happy path; write to a read-only stream β†’ `AKERR_IO`; write to a full + device (`/dev/full`) β†’ `ENOSPC`/`AKERR_IO`; `fp == NULL` β†’ `AKERR_NULLPOINTER`. +- [ ] `aksl_fclose` happy path; `NULL` β†’ `AKERR_NULLPOINTER`; double-close is caught or + documented as UB. +- [ ] `aksl_fclose` on a stream whose buffered flush fails (`/dev/full`) β†’ non-zero `fclose` + surfaces `errno`. +- [ ] Round-trip test: `fopen` β†’ `fwrite` β†’ `fclose` β†’ `fopen` β†’ `fread` β†’ compare bytes. + +### 1.3 Formatted output + +- [ ] `aksl_printf` / `aksl_fprintf` / `aksl_sprintf` happy paths, asserting both the byte + count written through `count` and the produced text. +- [ ] Each of the three with every pointer argument NULL in turn β†’ `AKERR_NULLPOINTER`. +- [ ] `aksl_fprintf` to a closed / read-only stream β†’ error path, and confirm `*count` is not + left holding `-1` as if it were a valid length. +- [ ] `aksl_sprintf` with a format that overflows the destination β€” currently unbounded + (Β§2.2.4). Test the `aksl_snprintf` replacement once it exists. +- [ ] Regression test for the missing `va_end` (Β§2.1.4) β€” a test that calls each variadic + wrapper many times in a loop, run under valgrind/ASan. +- [ ] Format-string/arg mismatch is caught at compile time once + `__attribute__((format(printf, ...)))` is added (Β§2.2.5) β€” a negative compile test. + +### 1.4 String β†’ number + +- [ ] `aksl_atoi` / `atol` / `atoll` / `atof` happy paths, including negative values and + leading whitespace. +- [ ] NULL `nptr` and NULL `dest` for each β†’ `AKERR_NULLPOINTER`. +- [ ] **Non-numeric input** (`"not a number"`) β€” **[CONFIRMED]** currently returns *success* + with `*dest == 0`. Test the `AKERR_VALUE` behaviour once Β§2.1.5 is fixed. +- [ ] **Overflow** (`"99999999999999999999"`) β€” **[CONFIRMED]** currently returns success with + a garbage value (`-1` on this box). Test for `ERANGE`. +- [ ] Empty string, `" "`, `"12abc"` (trailing junk), `"0x10"`, `"inf"`/`"nan"` for `atof`. +- [ ] `LONG_MIN`/`LONG_MAX`/`LLONG_MIN`/`LLONG_MAX` boundary strings round-trip exactly. + +### 1.5 `aksl_realpath` + +- [ ] Happy path on an existing file and on a symlink chain; result matches `realpath(3)`. +- [ ] Non-existent path β†’ `ENOENT`. +- [ ] A path component that is not a directory β†’ `ENOTDIR`. +- [ ] Symlink loop β†’ `ELOOP`. +- [ ] `path == NULL` β†’ `AKERR_NULLPOINTER`. +- [ ] `resolved_path == NULL` β€” currently unchecked and leaks (Β§2.1.6). Test once fixed. +- [ ] A failure case where `resolved_path` is an *uninitialised* buffer β€” this is the crash + case in Β§2.1.6; run it under ASan/MSan. + +### 1.6 `aksl_strhash_djb2` + +- [ ] Known-answer vectors: `djb2("")` == 5381; a handful of fixed strings with their + pre-computed 32-bit values. +- [ ] `len == 0` returns 5381 regardless of `str` contents. +- [ ] NULL `str` and NULL `hashval` β†’ `AKERR_NULLPOINTER`. +- [ ] **High-bit bytes** (`"\xff\xfe"`) β€” pins down the sign-extension bug in Β§2.2.6; the + expected value must be the `unsigned char` one. +- [ ] Embedded NUL bytes are hashed (the function is length-driven, not NUL-driven). +- [ ] Same input β†’ same output across two calls (no hidden state). + +### 1.7 Linked list + +- [ ] **`aksl_list_append` builds a correct chain of N nodes** β€” **[CONFIRMED BROKEN]**, see + Β§2.1.1. Appending `n1..n4` to `n0` yields the chain `n0 -> n4`; `n1`, `n2`, `n3` are + silently dropped. This is the single most important test to add. +- [ ] `append` sets `obj->prev` to the real tail and `obj->next` to NULL. +- [ ] `append` onto an empty (single, zeroed) node. +- [ ] `append` NULL list / NULL obj β†’ `AKERR_NULLPOINTER`. +- [ ] `append` onto a list containing a cycle β†’ `AKERR_CIRCULAR_REFERENCE`, for a self-loop, + a 2-node cycle, and a cycle that does not include the head. +- [ ] `append` a node that is already in the list (aliasing) β€” define and test the contract. +- [ ] **`aksl_list_iterate` visits every node exactly once, starting at the head** β€” + **[CONFIRMED BROKEN]**, see Β§2.1.2: it starts iterating from the *midpoint* left behind + by the cycle detector, so the head is never visited. Assert both the visit count and + the visit order. +- [ ] `iterate` over a single-node list visits exactly one node. +- [ ] `iterate` NULL list / NULL iter β†’ `AKERR_NULLPOINTER`. +- [ ] `iterate` over a cyclic list β†’ `AKERR_CIRCULAR_REFERENCE` (self-loop, 2-node, + tail-to-middle). +- [ ] `iterate` where the callback raises `AKERR_ITERATOR_BREAK` β†’ iteration stops at that + node, the wrapper returns success, and the visit count proves the early exit. +- [ ] `iterate` where the callback raises some *other* error β†’ that error propagates out + unchanged, with the callback's message intact. +- [ ] `aksl_list_pop` on a middle node relinks `prev`/`next` correctly and clears the popped + node's pointers. +- [ ] `pop` on the head node, on the tail node, and on a single-node list. +- [ ] `pop(NULL)` β†’ `AKERR_NULLPOINTER`. +- [ ] `pop` then `iterate` β€” the list is still traversable and the popped node is gone. +- [ ] Pool-accounting test: after a long sequence of list operations including failures, + `akerr_slots_in_use() == 0`. + +### 1.8 Tree + +- [ ] Visit **order** assertions, not just counts, for `DFS_PREORDER`, `DFS_INORDER` and + `DFS_POSTORDER` over the 7-node tree β€” record the visited node pointers into an array + and compare against the expected sequence. The current test only counts steps, which + cannot distinguish the three orders (all three visit 7 nodes). +- [ ] **`AKERR_ITERATOR_BREAK` aborts the whole traversal** β€” **[CONFIRMED BROKEN]**, see + Β§2.1.3. Break at `tree[3]` in pre-order and assert 4 visits; today it visits all 7. + Add the equivalent for in-order and post-order. +- [ ] `AKSL_TREE_SEARCH_BFS` and `AKSL_TREE_SEARCH_BFS_RIGHT` β†’ `AKERR_NOT_IMPLEMENTED` + today; replace with real order assertions once implemented (Β§3 / Β§2.2.9). +- [ ] **Unknown `searchmode`** (e.g. `99`) β€” **[CONFIRMED]** currently returns *success* + having visited nothing. Should be `AKERR_VALUE`. +- [ ] **`AKSL_TREE_SEARCH_VISIT`** (defined in the header, `5`) β€” **[CONFIRMED]** falls into + the same silent-success hole. Either implement it or reject it. +- [ ] `root == NULL` / `iter == NULL` β†’ `AKERR_NULLPOINTER`. +- [ ] Single-node tree (no children) visits exactly once, in all three orders. +- [ ] Left-only and right-only degenerate chains. +- [ ] Callback raising a non-`ITERATOR_BREAK` error propagates out of the recursion with the + original status and message. +- [ ] Custom `lalloc`/`lfree` are actually invoked β€” **currently they are stored and never + called** (Β§2.2.8), so this test will fail until BFS lands. +- [ ] Deep/degenerate tree (e.g. 100k-node left chain) β€” documents the recursion-depth limit + (Β§2.2.7). +- [ ] A tree containing a cycle (child pointing back at an ancestor) β€” currently infinite + recursion; test for `AKERR_CIRCULAR_REFERENCE` once guarded. + +### 1.9 Cross-cutting + +- [ ] **Error-pool accounting**: for *every* wrapper, a test that drives the failure path + `AKERR_MAX_ARRAY_ERROR + 10` times and asserts the pool does not leak + (`akerr_slots_in_use() == 0` after each handled error). +- [ ] **Stack-trace content**: assert that the file/function/line recorded by each wrapper's + `FAIL` points at `src/stdlib.c` and the right function name. +- [ ] **`AKERR_NOIGNORE` is effective**: a negative compile test where a wrapper's return is + discarded and `-Werror=unused-result` fires. +- [ ] **Thread safety**: libakerror's `AKERR_ARRAY_ERROR` is a process-global array with no + locking. If `libakstdlib` is meant to be callable from threads, add a concurrent + smoke test under TSan β€” and if it is not, say so in the README. + +--- + +## 2. Corner cases in existing functionality that should be resolved + +### 2.1 Confirmed defects (reproduced against the built library) + +The first three now have a failing test apiece, registered in `AKSL_KNOWN_FAILING_TESTS` +and therefore marked `WILL_FAIL` so the suite stays green while the gap stays visible: +`tests/test_list_append_chain.c`, `tests/test_list_iterate_head.c` and +`tests/test_tree_iterate_break.c`. When one of these defects is fixed, CTest reports that +test as failed with *unexpectedly passed* β€” that is the cue to move it into `AKSL_TESTS`. + +1. **`aksl_list_append` does not find the tail β€” it silently truncates the list.** + `src/stdlib.c:194`. The function conflates Floyd cycle detection with tail-finding: + `tail` is set to `slow` *before* `slow` advances, so it tracks the node *behind the + midpoint*, not the tail. Appending `n1`, `n2`, `n3`, `n4` to `n0` produces the chain + `n0 -> n4`; `n1`–`n3` are unlinked and lost. The fix is to keep the cycle check but walk + a separate cursor to the real tail (`while (tail->next) tail = tail->next;`), or run + Floyd first and then walk to the end. + +2. **`aksl_list_iterate` skips the first half of the list.** `src/stdlib.c:324`. After the + cycle-detection loop, `slow` is left at the list midpoint, and the visiting loop then + starts from `slow` instead of from `list`. The head node is never passed to the callback. + Fix: iterate from `list`, not from `slow`. + +3. **`AKERR_ITERATOR_BREAK` does not stop a tree traversal.** `src/stdlib.c:251`. The + recursive frame in which the callback raises the break handles it in its own + `PROCESS`/`HANDLE(e, AKERR_ITERATOR_BREAK)` block and returns *success*; the parent's + `PASS` therefore sees no error and continues on to the sibling subtree. Breaking at + `tree[3]` in pre-order still visits all 7 nodes. Fix by splitting the recursion: an + internal helper that propagates `AKERR_ITERATOR_BREAK` unhandled, and a public entry point + that swallows it exactly once at the top. `tests/test_tree.c` currently passes only + because the search target is the last node in all three orders. + +4. **`va_end` is never called.** `src/stdlib.c:96`, `:109`, `:122` each call `va_start` with + no matching `va_end` on any path β€” happy or error. This is undefined behaviour per the C + standard and leaks register-save state on some ABIs. + +5. **`aksl_atoi`/`atol`/`atoll`/`atof` cannot report a conversion failure.** + `src/stdlib.c:135`–`169`. `atoi("not a number")` returns success with `0`; + `atoi("99999999999999999999")` returns success with a wrapped value. Since the entire + point of this library is turning silent libc failures into error contexts, these should be + reimplemented over `strtol`/`strtoll`/`strtod` with `errno = 0` before the call, an + `endptr` check for "no digits consumed" and "trailing junk", and a range check β€” + raising `AKERR_VALUE` and `ERANGE` respectively. Keep the `atoi`-compatible names but + document the stricter contract, or add `aksl_strtol`-family wrappers alongside. + +6. **`aksl_realpath` mishandles `resolved_path`.** `src/stdlib.c:171`. + - `resolved_path` is never NULL-checked. `realpath(path, NULL)` is valid and mallocs a + buffer, but the wrapper discards `result`, so the caller gets nothing and the buffer + leaks. + - On failure the message formats `resolved_path` with `%s` while `realpath` leaves it + unspecified β€” for the normal caller who passed an uninitialised stack buffer, the error + path itself reads uninitialised memory and can crash. + - There is no way to express the buffer's size, so callers must know to supply `PATH_MAX` + bytes. Consider `aksl_realpath(const char *path, char *buf, size_t buflen)`, or an + allocating variant that returns the malloc'd pointer through an out-param. + +### 2.2 Latent issues and API contract gaps + +1. **`errno` is used as an error status where it may be stale.** `aksl_malloc` reports + `errno` when `malloc` returns NULL, but `malloc(0)` may legitimately return NULL without + setting `errno`, producing a `FAIL` with `status == 0` β€” an error context that every + `DETECT`/`CATCH` will read as *success* while still holding a pool slot. Guard every + `errno`-sourced status with a fallback (`errno ? errno : AKERR_IO`) and set `errno = 0` + before the call being wrapped. + +2. **`aksl_fopen` does not validate `pathname` or `mode`.** `fopen(NULL, ...)` is UB. Add + `AKERR_NULLPOINTER` guards. + +3. **`aksl_fread`/`aksl_fwrite` lose the transfer count and hide short transfers.** + - `ptr` is never NULL-checked in either function. + - Neither has an out-param for the number of members actually transferred, so a caller who + gets `AKERR_EOF` cannot tell how much data arrived. Add `size_t *nmemb_out`. + - If `nmemr != nmemb` but neither `feof` nor `ferror` is set, both functions fall through + to `SUCCEED_RETURN` β€” a short transfer reported as complete success. + - `aksl_fwrite`'s error message reads `"Error reading file"` (`src/stdlib.c:83`), and + checking `feof()` on a write path is meaningless. + +4. **`aksl_sprintf` wraps `vsprintf`, which is unbounded.** There is no way for a caller to + bound the destination. Add `aksl_snprintf`/`aksl_vsnprintf` and consider deprecating the + `sprintf` wrapper, or reject it outright β€” an "error-handling" wrapper around an + unbounded write is a sharp edge the library exists to remove. + +5. **No `format` attribute on the variadic wrappers.** Adding + `__attribute__((format(printf, 2, 3)))` (and the equivalents) restores the compile-time + format/argument checking that callers lose by going through the wrapper. + +6. **`aksl_strhash_djb2` sign-extends bytes β‰₯ 0x80.** `src/stdlib.c:181` iterates a + `char *`, which is signed on x86/ARM Linux, so high bytes contribute a sign-extended + negative value and the hash differs from the canonical djb2 β€” and differs across + platforms where `char` is unsigned. Iterate a `const unsigned char *`. Also take + `const char *` in the signature so callers need not cast away constness. + +7. **`aksl_tree_iterate` recursion is unbounded and cycle-blind.** A deep or degenerate tree + overflows the stack, and a child pointer that loops back to an ancestor recurses forever. + Add a depth limit (raising `AKERR_OUTOFBOUNDS`) and/or a visited check raising + `AKERR_CIRCULAR_REFERENCE`, consistent with what the list functions already do. + +8. **`lalloc`, `lfree` and `queue` are dead parameters.** `lalloc`/`lfree` are defaulted and + then never called; `queue` is entirely unused (it is the only `-Wunused-parameter` warning + in the file). The doc comment even tells the caller to "pass NULL here", which is a sign + the queue belongs in an internal helper rather than the public signature. Either + implement BFS so they are used, or drop them from the public API until it is. + +9. **Unknown `searchmode` values return success.** The `switch` in `aksl_tree_iterate` has no + `default:`; `searchmode == 99` and the header-defined `AKSL_TREE_SEARCH_VISIT` (5) both + fall straight through to `SUCCEED_RETURN` having visited nothing. Add + `default: FAIL_RETURN(e, AKERR_VALUE, ...)`. + +10. **`AKSL_TREE_SEARCH_BFS`/`BFS_RIGHT` are unimplemented** (`AKERR_NOT_IMPLEMENTED`), and + `AKSL_TREE_SEARCH_VISIT` is declared in the header with a documented meaning but no + implementation anywhere. + +11. **`aksl_memset`'s and `aksl_memcpy`'s inner checks are dead code.** `memset` returns `s` + and `memcpy` returns `d`, both unconditionally; neither can fail. The + `FAIL_ZERO_RETURN(e, memset(...), errno, ...)` and `(memcpy(...) == d)` checks can never + fire and only obscure the intent. Also, `memcpy` with overlapping ranges is UB β€” either + document that or dispatch to `memmove`. + +12. **`aksl_list_pop` cannot tell the caller the new head.** Popping the head leaves the + caller's head pointer dangling at a now-detached node. Add an out-param for the new head, + or a `aksl_list_pop(aksl_ListNode **head, aksl_ListNode *node)` form. + +13. **`aksl_free` does not clear the caller's pointer**, so double-free remains easy. Consider + an `aksl_freep(void **ptr)` that frees and NULLs. + +14. **No initialisers for the public structs.** Every caller must remember to `memset` an + `aksl_ListNode`/`aksl_TreeNode` to zero before use (both existing tests do). Add + `aksl_list_node_init`/`aksl_tree_node_init` or `AKSL_LIST_NODE_INIT` macros. + +15. **`aksl_TreeNode.parent` is declared but never set or read** by any library function. + +16. **Header hygiene**: no `extern "C" { }` guard for C++ consumers; no version macros + (`AKSL_VERSION_MAJOR`…); `akstdlib.h` pulls in `stdio.h`/`stdlib.h`/`string.h`/`stdint.h` + into every consumer's namespace. + +17. **Doxygen coverage is 2 functions out of 20.** A `Doxyfile` exists but only + `aksl_tree_iterate` and `aksl_list_iterate` have doc comments. Every public function + needs `@param`/`@throws`/`@return`, especially the ones whose contract deviates from libc + (`aksl_free(NULL)` is an error; `aksl_atoi` will become strict). + +### 2.3 Build, CI and repository + +- [ ] **`CMakeLists.txt:4-6` sets `CMAKE_CXX_FLAGS` in a C-only project**, so `-g -ggdb -pg` + never reach the C compiler. Use `CMAKE_C_FLAGS` β€” or better, drop `-pg` from the + default build entirely (it is what produces the stray `gmon.out` sitting untracked in + the repo root) and let `CMAKE_BUILD_TYPE` control debug info. +- [ ] **No warning flags.** Add `-Wall -Wextra` (and ideally `-Werror` in CI). The only + current warning is the unused `queue` parameter, so the cost of turning them on is low. +- [ ] `src/stdlib.c` triggers **"ISO C99 requires at least one argument for the `...`"** on + roughly 20 lines under `-Wpedantic`, because `FAIL_*` is called with a bare message and + no varargs. Either always pass an argument, or add a zero-arg-safe form in libakerror. +- [ ] **The `deps/libakerror` submodule is pinned 11 commits behind `main`** (pinned at + `4fad0ce "Add gitea workflow"`; upstream is at `4212ff0`). The pin predates the + refcount-leak fix, the stack-trace buffer-overflow fix, the `AKERR_MAX_ERR_VALUE` + correction and the format-string fix. Bump it. +- [ ] **CI does not build against the submodule it pins.** `.gitea/workflows/ci.yaml` clones + `libakerror@main` and installs it, while the build it then runs is top-level and so + compiles `deps/libakerror` at the pinned commit β€” two different libakerror versions + depending on where you build, and the installed one is never actually linked. Pick + one. (The submodule is at least *present* in CI now: Β§1.0 added + `submodules: recursive` to the checkout, without which configure failed outright.) +- [x] **`ctest` was red** (`tree` failing plus four *Not Run* submodule tests) β€” fixed in + Β§1.0. The build badge in `README.md` means something again. +- [ ] **Untracked build litter** in the repo root: `build/`, `gmon.out`, + `CMakeLists.txt-akstdlib`, and a dozen `*~` editor backups. Add a `.gitignore` + (libakerror has one; this repo does not). +- [ ] **`README.md` is a build badge and nothing else.** It needs at minimum: what the + library is, the akerror wrapper contract, the list of wrapped functions, and the + deviations from libc semantics. +- [ ] **Indentation is inconsistent** β€” `src/stdlib.c` mixes hard tabs and 4-space indents + within the same functions. libakerror standardised on Stroustrup style + (commit `e5f7616`); apply the same here, ideally with a checked-in `.clang-format`. + +--- + +## 3. libc functions not yet wrapped + +Ordered roughly by how much a caller of this library would miss them. Each entry means "add +an `aksl_`-prefixed wrapper that turns the documented failure modes into an +`akerr_ErrorContext`". + +### 3.1 High priority β€” gaps in areas the library already covers + +**Memory (`stdlib.h`, `string.h`)** +- [ ] `calloc`, `realloc`, `reallocarray` β€” `realloc`'s "returns NULL and the old pointer is + still valid" trap is exactly what this library should be hiding. +- [ ] `aligned_alloc`, `posix_memalign` +- [ ] `memmove`, `memcmp`, `memchr` + +**Bounded string formatting (`stdio.h`)** +- [ ] `snprintf`, `vsnprintf` β€” needed to make Β§2.2.4 fixable. +- [ ] `vprintf`, `vfprintf`, `vsprintf` β€” the `va_list` forms, so consumers can build their + own variadic wrappers on top. +- [ ] `asprintf` / `vasprintf` (GNU) as an allocating alternative. + +**String β†’ number (`stdlib.h`)** +- [ ] `strtol`, `strtoll`, `strtoul`, `strtoull`, `strtod`, `strtof`, `strtold` β€” the correct + foundation for Β§2.1.5. + +**Strings (`string.h`)** +- [ ] `strlen`, `strnlen` +- [ ] `strcpy`, `strncpy`, `strcat`, `strncat` β€” with truncation reported as an error rather + than silently accepted, which is the whole value proposition here. +- [ ] `strdup`, `strndup` +- [ ] `strcmp`, `strncmp`, `strcasecmp`, `strncasecmp`, `strcoll` +- [ ] `strchr`, `strrchr`, `strstr`, `strcasestr`, `strpbrk`, `strspn`, `strcspn` +- [ ] `strtok_r`, `strsep` +- [ ] `strerror_r` + +**Stream I/O (`stdio.h`)** +- [ ] `fseek`, `ftell`, `rewind`, `fgetpos`, `fsetpos`, `fseeko`, `ftello` +- [ ] `fflush` +- [ ] `fgets`, `fputs`, `fgetc`/`getc`/`getchar`, `fputc`/`putc`/`putchar`, `ungetc` +- [ ] `getline`, `getdelim` +- [ ] `freopen`, `fdopen`, `fileno` +- [ ] `setvbuf`, `setbuf` +- [ ] `clearerr`, `feof`, `ferror` β€” thin, but worth exposing so callers never touch raw + `FILE *` internals. +- [ ] `sscanf`, `fscanf`, `scanf` β€” the return-value semantics (items matched vs `EOF`) are a + classic silent-failure source. +- [ ] `remove`, `rename`, `tmpfile`, `mkstemp`, `mkdtemp` +- [ ] `perror` β€” or an akerror-native equivalent. + +### 3.2 POSIX file and process API (likely the next major surface) + +**`unistd.h` / `fcntl.h`** +- [ ] `open`, `close`, `read`, `write`, `pread`, `pwrite`, `lseek` +- [ ] `readv`, `writev` +- [ ] `dup`, `dup2`, `pipe`, `fcntl` +- [ ] `fsync`, `fdatasync`, `truncate`, `ftruncate` +- [ ] `unlink`, `link`, `symlink`, `readlink`, `rmdir`, `mkdir` +- [ ] `access`, `faccessat`, `chmod`, `fchmod`, `chown`, `fchown`, `umask` +- [ ] `chdir`, `fchdir`, `getcwd` +- [ ] `isatty`, `ttyname_r` +- [ ] `sysconf`, `pathconf` +- [ ] `sleep`, `usleep`, `nanosleep` + +**`sys/stat.h`** +- [ ] `stat`, `fstat`, `lstat`, `fstatat` +- [ ] `statvfs`, `fstatvfs` + +**`dirent.h`** +- [ ] `opendir`, `fdopendir`, `readdir`, `readdir_r`, `closedir`, `rewinddir`, `scandir` + +**Process control** +- [ ] `fork`, `execve` / `execvp` / `execl` family, `waitpid`, `wait` +- [ ] `posix_spawn` +- [ ] `system`, `popen`, `pclose` +- [ ] `getpid`, `getppid`, `getuid`, `geteuid`, `setuid`, `setgid` +- [ ] `atexit`, `exit`, `_exit`, `abort` β€” mostly to give akerror a hook on shutdown. +- [ ] `getenv`, `setenv`, `unsetenv`, `putenv`, `clearenv` + +**`sys/mman.h`** +- [ ] `mmap`, `munmap`, `mprotect`, `msync`, `madvise` + +### 3.3 Time + +- [ ] `time`, `clock_gettime`, `clock_getres`, `gettimeofday` +- [ ] `localtime_r`, `gmtime_r`, `mktime`, `timegm`, `difftime` +- [ ] `strftime`, `strptime` +- [ ] `clock`, `times` + +### 3.4 Sorting, searching and misc `stdlib.h` + +- [ ] `qsort`, `qsort_r`, `bsearch` β€” comparator errors currently have nowhere to go; an + akerror-aware comparator signature would be a genuine improvement. +- [ ] `abs`, `labs`, `llabs`, `div`, `ldiv`, `lldiv` +- [ ] `rand`, `srand`, `random`, `srandom`, `getrandom`/`arc4random` + +### 3.5 Lower priority / larger projects + +- [ ] **Sockets**: `socket`, `bind`, `listen`, `accept`, `connect`, `send`/`sendto`/`sendmsg`, + `recv`/`recvfrom`/`recvmsg`, `shutdown`, `setsockopt`/`getsockopt`, `getaddrinfo`/ + `freeaddrinfo`/`gai_strerror`, `inet_ntop`/`inet_pton` +- [ ] **Multiplexing**: `select`, `poll`, `ppoll`, `epoll_create1`/`epoll_ctl`/`epoll_wait` +- [ ] **Signals**: `sigaction`, `sigprocmask`, `sigemptyset`/`sigaddset`, `kill`, `raise`, + `signalfd` +- [ ] **Threads**: `pthread_create`/`join`/`detach`, `pthread_mutex_*`, `pthread_cond_*`, + `pthread_rwlock_*`, `sem_*` β€” blocked on resolving the thread-safety question in Β§1.9 + (libakerror's error pool is an unlocked process-global array). +- [ ] **Dynamic loading**: `dlopen`, `dlsym`, `dlclose`, `dlerror` +- [ ] **Locale / wide chars**: `setlocale`, `mbstowcs`, `wcstombs`, `iconv_*` +- [ ] **Math**: the `math.h` functions that set `errno`/raise FP exceptions (`sqrt`, `log`, + `pow`, `acos`, …) β€” probably better served by a dedicated `libakmath`. + +### 3.6 Non-libc additions the current data structures imply + +Not libc wrappers, but the existing list/tree API is visibly incomplete: + +- [ ] List: `prepend`, `insert_after`/`insert_before`, `length`, `find`, `reverse`, + `concat`, `free_all`, reverse iteration, and a head/tail-tracking container type so + `append` is O(1) instead of O(n). +- [ ] Tree: `insert`, `remove`, `find`, `height`, `count`, `free_all`, and the BFS traversal + the `lalloc`/`lfree`/`queue` parameters were designed for. +- [ ] Hash map built on `aksl_strhash_djb2`, plus additional hashes (FNV-1a, xxHash) and a + NUL-terminated `aksl_strhash_djb2_str` convenience form. +- [ ] Growable buffer / string-builder type, to make the `snprintf` and `strcat` wrappers + pleasant to use. diff --git a/scripts/mutation_test.py b/scripts/mutation_test.py new file mode 100755 index 0000000..b0a38a7 --- /dev/null +++ b/scripts/mutation_test.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +""" +Mutation testing harness for libakstdlib. + +Mutation testing measures how good the test suite is at catching bugs. It works +by making many small, deliberate breakages ("mutants") to the library source -- +flipping a comparison, deleting a statement, swapping true/false -- and then +running the whole CTest suite against each one. If the tests fail, the mutant is +"killed" (good: the tests noticed the bug). If the tests still pass, the mutant +"survived" (bad: a real bug of that shape would slip through unnoticed). + +The mutation score is killed / (killed + survived). Surviving mutants are printed +with file:line and the exact change so they can be turned into new test cases. + +This harness has no third-party dependencies (Python stdlib + the project's +normal cmake/ctest toolchain). It never mutates the real working tree: it copies +the repo to a scratch directory and mutates there. + +Usage: + scripts/mutation_test.py [options] + + --source-root DIR repo root to copy (default: parent of this script's dir) + --target FILE source file to mutate, relative to root; repeatable. + Default: src/stdlib.c and include/akstdlib.h + --work DIR scratch dir for the mutated copy (default: a temp dir) + --timeout SECONDS per-suite ctest timeout (default: 120) + --threshold PCT exit non-zero if mutation score < PCT (default: 0 = off) + --list only list the mutants that would be run, then exit + --keep keep the scratch working copy on exit (for debugging) + -j N (reserved) currently runs sequentially +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile + +# --------------------------------------------------------------------------- # +# Mutation operators +# +# Each operator yields zero or more (start, end, replacement) edits for a single +# line of source. The driver applies exactly one edit per mutant so every mutant +# differs from the original by one localized change. +# --------------------------------------------------------------------------- # + +# Relational operator replacement: map each operator to the alternatives that +# meaningfully change behaviour (not merely the strict negation). +_REL = { + "==": ["!="], + "!=": ["=="], + "<=": ["<", "=="], + ">=": [">", "=="], + "<": ["<=", ">"], + ">": [">=", "<"], +} +# Match a relational operator that is NOT part of ->, <<, >>, =>, <=, >=, ==, != +# unless we intend it. We tokenize the two-char operators first, then single. +_REL_TWO = re.compile(r"(==|!=|<=|>=)") +_REL_ONE = re.compile(r"(?=!+])([<>])(?![=<>])") + +_LOGICAL = {"&&": "||", "||": "&&"} +_LOG_RE = re.compile(r"(&&|\|\|)") + +_BOOL = {"true": "false", "false": "true"} +_BOOL_RE = re.compile(r"\b(true|false)\b") + +# Arithmetic / compound-assignment on whitespace-delimited operands only, to +# avoid touching ++, --, ->, unary signs, or pointer/format punctuation. +_ARITH_RE = re.compile(r"(?<=\s)([+\-])(?=\s)") +_ARITH = {"+": "-", "-": "+"} +_COMPOUND_RE = re.compile(r"(\+=|-=)") +_COMPOUND = {"+=": "-=", "-=": "+="} + +# Integer literal replacement: 0 <-> 1 (word-bounded, not inside identifiers or +# larger numbers, not a float). +_INT_RE = re.compile(r"(?().\[\]* ]*\s*=\s*[^;]* | # assignments + [A-Za-z_][\w]*\s*\([^;]*\) # bare function calls + )\s*;\s*(\\?)\s*$""", + re.VERBOSE, +) + + +# --------------------------------------------------------------------------- # +# Deciding which lines are eligible to mutate +# --------------------------------------------------------------------------- # + +# Skip preprocessor control and the block of constant/error-code #defines in the +# template header: mutating buffer sizes or renumbering error codes produces +# equivalent or uninteresting mutants that swamp the signal. +_SKIP_LINE = re.compile( + r"""^\s*( + \#\s*(include|ifn?def|ifdef|if|elif|else|endif|error|pragma|undef) | + \#\s*define\s+AKERR_(MAX|LAST|NULLPOINTER|OUTOFBOUNDS|API|ATTRIBUTE| + TYPE|KEY|INDEX|FORMAT|IO|VALUE|RELATIONSHIP|EOF|CIRCULAR_REFERENCE| + ITERATOR_BREAK|NOT_IMPLEMENTED|BADEXC|NOIGNORE|USE_STDLIB)\b | + \* | // # comment bodies / line comments + )""", + re.VERBOSE, +) + + +def _is_comment_or_blank(line): + s = line.strip() + return (not s) or s.startswith("//") or s.startswith("/*") or s.startswith("*") + + +def eligible(line): + if _is_comment_or_blank(line): + return False + if _SKIP_LINE.match(line): + return False + return True + + +class Mutant: + __slots__ = ("path", "lineno", "op", "before", "after", "col") + + def __init__(self, path, lineno, op, before, after, col): + self.path = path + self.lineno = lineno + self.op = op + self.before = before + self.after = after + self.col = col + + def describe(self): + return (f"{self.path}:{self.lineno} [{self.op}] " + f"col{self.col}: {self.before.strip()} -> {self.after.strip()}") + + +def generate_mutants(root, rel_target): + """Enumerate all mutants for one target file.""" + abspath = os.path.join(root, rel_target) + with open(abspath, "r") as fh: + lines = fh.readlines() + + mutants = [] + for i, line in enumerate(lines, start=1): + if not eligible(line): + continue + # substitution operators + seen = set() + for tag, s, e, repl in _op_edits(line): + key = (s, e, repl) + if key in seen: + continue + seen.add(key) + mutated = line[:s] + repl + line[e:] + if mutated == line: + continue + mutants.append(Mutant(rel_target, i, tag, line, mutated, s)) + # statement deletion + m = _STMT_DELETABLE.match(line) + if m: + indent = line[: len(line) - len(line.lstrip())] + cont = "\\" if line.rstrip().endswith("\\") else "" + deleted = f"{indent}/* mutant: deleted */ {cont}\n" if cont else f"{indent};\n" + mutants.append(Mutant(rel_target, i, "SDL", line, deleted, 0)) + return mutants + + +# --------------------------------------------------------------------------- # +# Build / test orchestration against a scratch copy +# --------------------------------------------------------------------------- # + +class Runner: + def __init__(self, work, timeout): + self.work = work + self.build = os.path.join(work, "build") + self.timeout = timeout + + def _run(self, cmd, timeout=None): + return subprocess.run( + cmd, cwd=self.work, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=timeout, + ) + + def configure(self): + r = self._run(["cmake", "-S", ".", "-B", "build"], timeout=self.timeout) + return r.returncode == 0, r.stdout + + def build_and_test(self): + """Return ('killed-compile' | 'killed-test' | 'killed-timeout' | 'survived').""" + try: + b = self._run(["cmake", "--build", "build"], timeout=self.timeout) + except subprocess.TimeoutExpired: + return "killed-timeout" + if b.returncode != 0: + return "killed-compile" + try: + t = subprocess.run( + ["ctest", "--test-dir", "build", "--output-on-failure", + "--stop-on-failure"], + cwd=self.work, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=self.timeout, + ) + except subprocess.TimeoutExpired: + return "killed-timeout" + return "survived" if t.returncode == 0 else "killed-test" + + +def _xml_escape(s): + return (s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def write_junit(path, records, targets): + """Write a JUnit XML report. One per mutant; a surviving mutant + is a (test-suite gap), a killed mutant is a passing case.""" + by_file = {t: [] for t in targets} + for m, result in records: + by_file.setdefault(m.path, []).append((m, result)) + + total = len(records) + total_fail = sum(1 for _, r in records if r == "survived") + out = ['', + f''] + for f, items in by_file.items(): + if not items: + continue + fails = sum(1 for _, r in items if r == "survived") + out.append(f' ') + for m, result in items: + name = _xml_escape(m.describe()) + cls = "mutation." + _xml_escape(m.path) + if result == "survived": + detail = _xml_escape(f"{m.before.strip()} -> {m.after.strip()}") + out.append(f' ') + out.append(f' {detail}') + out.append(' ') + else: + out.append(f' {_xml_escape(result)}' + '') + out.append(' ') + out.append('') + with open(path, "w") as fh: + fh.write("\n".join(out) + "\n") + + +def copy_tree(src, dst): + ignore = shutil.ignore_patterns("build", ".git", "*.o", "*.so", "*~", + "#*#", "*.iso", "*.png") + shutil.copytree(src, dst, ignore=ignore, symlinks=True) + + +def read_lines(path): + with open(path) as fh: + return fh.readlines() + + +def write_lines(path, lines): + with open(path, "w") as fh: + fh.writelines(lines) + + +def main(): + # Line-buffer stdout so progress is visible live under CI / the cmake target. + try: + sys.stdout.reconfigure(line_buffering=True) + except (AttributeError, ValueError): + pass + here = os.path.dirname(os.path.abspath(__file__)) + default_root = os.path.dirname(here) + + ap = argparse.ArgumentParser(description="Mutation testing for libakstdlib") + ap.add_argument("--source-root", default=default_root) + ap.add_argument("--target", action="append", default=None) + ap.add_argument("--work", default=None) + ap.add_argument("--timeout", type=int, default=120) + ap.add_argument("--threshold", type=float, default=0.0) + ap.add_argument("--junit", default=None, + help="write a JUnit XML report to this path") + ap.add_argument("--max-mutants", type=int, default=0, + help="cap the run at N evenly-sampled mutants (0 = all)") + ap.add_argument("--list", action="store_true") + ap.add_argument("--keep", action="store_true") + ap.add_argument("-j", type=int, default=1) + args = ap.parse_args() + + root = os.path.abspath(args.source_root) + targets = args.target or ["src/stdlib.c", "include/akstdlib.h"] + + # Enumerate mutants from the pristine sources. + all_mutants = [] + for t in targets: + all_mutants.extend(generate_mutants(root, t)) + + print(f"Generated {len(all_mutants)} mutants across {len(targets)} file(s):") + for t in targets: + n = sum(1 for m in all_mutants if m.path == t) + print(f" {t}: {n}") + + # Optional even-strided sampling to bound run time (CI / smoke tests). + if args.max_mutants and len(all_mutants) > args.max_mutants: + step = len(all_mutants) / args.max_mutants + sampled = [all_mutants[int(i * step)] for i in range(args.max_mutants)] + print(f"Sampling {len(sampled)} of {len(all_mutants)} mutants " + f"(--max-mutants {args.max_mutants}).") + all_mutants = sampled + + if args.list: + for m in all_mutants: + print(" " + m.describe()) + return 0 + + if not all_mutants: + print("No mutants generated; nothing to do.") + return 0 + + # Scratch working copy. + work_parent = args.work or tempfile.mkdtemp(prefix="akerr_mut_") + work = os.path.join(work_parent, "src") if args.work else work_parent + if os.path.exists(work): + shutil.rmtree(work) + print(f"\nCopying sources to scratch dir: {work}") + copy_tree(root, work) + + runner = Runner(work, args.timeout) + + print("Configuring baseline ...") + ok, out = runner.configure() + if not ok: + sys.stderr.write(out.decode(errors="replace")) + sys.stderr.write("\nBaseline configure FAILED; aborting.\n") + return 2 + + print("Verifying baseline is green (no mutation) ...") + baseline = runner.build_and_test() + if baseline != "survived": + sys.stderr.write(f"Baseline is not green ({baseline}); aborting. " + "Fix the suite before mutation testing.\n") + return 2 + print("Baseline OK.\n") + + # Group mutants by file so we mutate one file at a time and restore it. + killed = {"killed-compile": 0, "killed-test": 0, "killed-timeout": 0} + survivors = [] + records = [] + total = len(all_mutants) + + # Cache pristine contents per target. + pristine = {t: read_lines(os.path.join(work, t)) for t in targets} + + for idx, m in enumerate(all_mutants, start=1): + tgt_abs = os.path.join(work, m.path) + lines = list(pristine[m.path]) + lines[m.lineno - 1] = m.after + write_lines(tgt_abs, lines) + try: + result = runner.build_and_test() + finally: + write_lines(tgt_abs, pristine[m.path]) # always restore + + records.append((m, result)) + if result == "survived": + survivors.append(m) + mark = "SURVIVED" + else: + killed[result] += 1 + mark = result.upper() + print(f"[{idx}/{total}] {mark:16} {m.describe()}") + + total_killed = sum(killed.values()) + score = 100.0 * total_killed / total if total else 100.0 + + print("\n" + "=" * 72) + print("MUTATION TESTING SUMMARY") + print("=" * 72) + print(f" total mutants : {total}") + print(f" killed (test) : {killed['killed-test']}") + print(f" killed (compile): {killed['killed-compile']}") + print(f" killed (timeout): {killed['killed-timeout']}") + print(f" survived : {len(survivors)}") + print(f" mutation score : {score:.1f}%") + if survivors: + print("\nSurviving mutants (test-suite gaps -- turn these into tests):") + for m in survivors: + print(" " + m.describe()) + + if args.junit: + junit_path = os.path.abspath(args.junit) + write_junit(junit_path, records, targets) + print(f"\nJUnit report written to: {junit_path}") + + if not args.keep and not args.work: + shutil.rmtree(work_parent, ignore_errors=True) + else: + print(f"\nScratch working copy kept at: {work}") + + if args.threshold > 0 and score < args.threshold: + print(f"\nFAIL: mutation score {score:.1f}% < threshold {args.threshold:.1f}%") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/aksl_capture.h b/tests/aksl_capture.h new file mode 100644 index 0000000..a13eed2 --- /dev/null +++ b/tests/aksl_capture.h @@ -0,0 +1,216 @@ +#ifndef AKSL_TEST_CAPTURE_H +#define AKSL_TEST_CAPTURE_H + +/* + * Shared test helpers for libakstdlib. + * + * Modelled on libakerror's tests/err_capture.h, with additions for the shape of + * this library: almost every akstdlib entry point returns an + * akerr_ErrorContext * that the caller owns and must release, so the common + * assertion is "this call returned status X" rather than "this call logged Y". + * + * What is here: + * + * AKSL_CHECK() an NDEBUG-proof assertion. Fails the test by + * returning 1 from the enclosing function (unlike + * assert(), which is compiled out in release builds + * and would silently turn a test into a no-op). + * AKSL_CHECK_STATUS() run an akerror-returning expression, assert on the + * status it came back with, and release the context + * so the error pool does not leak. + * AKSL_CHECK_OK() the status == 0 (success) case of the above. + * aksl_last_status/... the status, message and function name of the most + * recent context taken by AKSL_CHECK_STATUS. + * aksl_capture_install() swap in a capturing akerr_log_method so a test can + * assert on the *content* of stack traces and + * unhandled-error output. + * aksl_slots_in_use() how many slots are currently checked out of + * AKERR_ARRAY_ERROR, for pool-leak assertions. + * AKSL_RUN() run one test function and tally the result. + * + * Tests are written as a set of `static int test_xxx(void)` functions that + * return 0 on success and non-zero on failure, driven from main() by AKSL_RUN. + */ + +#include +#include +#include +#include + +/* ---------------------------------------------------------------------- */ +/* Log capture */ +/* ---------------------------------------------------------------------- */ + +#define AKSL_CAPTURE_BUFSZ 65536 +static char aksl_capture_buf[AKSL_CAPTURE_BUFSZ]; +static size_t aksl_capture_len = 0; + +static void __attribute__((unused)) aksl_capture_logger(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(aksl_capture_buf + aksl_capture_len, + AKSL_CAPTURE_BUFSZ - aksl_capture_len, fmt, ap); + va_end(ap); + if ( n > 0 ) { + aksl_capture_len += (size_t)n; + if ( aksl_capture_len >= AKSL_CAPTURE_BUFSZ ) { + aksl_capture_len = AKSL_CAPTURE_BUFSZ - 1; + } + } +} + +static void __attribute__((unused)) aksl_capture_reset(void) +{ + aksl_capture_len = 0; + aksl_capture_buf[0] = '\0'; +} + +/* + * Install the capturing logger. akerr_init() only assigns a default logger when + * akerr_log_method is NULL, and it is idempotent, so calling this either before + * or after the first PREPARE_ERROR keeps our logger in place. + */ +static void __attribute__((unused)) aksl_capture_install(void) +{ + aksl_capture_reset(); + akerr_log_method = &aksl_capture_logger; +} + +/* ---------------------------------------------------------------------- */ +/* Error pool accounting */ +/* ---------------------------------------------------------------------- */ + +/* Count array slots currently checked out of the pool (refcount != 0). */ +static int __attribute__((unused)) aksl_slots_in_use(void) +{ + int n = 0; + for ( int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) { + if ( AKERR_ARRAY_ERROR[i].refcount != 0 ) { + n++; + } + } + return n; +} + +/* ---------------------------------------------------------------------- */ +/* Taking ownership of a returned error context */ +/* ---------------------------------------------------------------------- */ + +static int aksl_last_status = 0; +static char aksl_last_message[AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH]; +/* Sized to match akerr_ErrorContext.function, which akerror declares with + * AKERR_MAX_ERROR_FNAME_LENGTH rather than AKERR_MAX_ERROR_FUNCTION_LENGTH. */ +static char aksl_last_function[AKERR_MAX_ERROR_FNAME_LENGTH]; + +/* + * Record the status/message/function of a returned context, release it back to + * the pool, and hand back the status. A NULL context means success, which is + * status 0. Every akstdlib call in a test should go through this (or through + * AKSL_CHECK_STATUS, which wraps it) so that no test leaks a pool slot. + */ +static int __attribute__((unused)) aksl_take(akerr_ErrorContext *e) +{ + akerr_ErrorContext *released = NULL; + + aksl_last_message[0] = '\0'; + aksl_last_function[0] = '\0'; + if ( e == NULL ) { + aksl_last_status = 0; + return 0; + } + aksl_last_status = e->status; + snprintf(aksl_last_message, sizeof(aksl_last_message), "%s", e->message); + snprintf(aksl_last_function, sizeof(aksl_last_function), "%s", e->function); + /* + * akerr_release_error is marked warn_unused_result, and a (void) cast does + * not silence that in GCC, so the result is assigned and discarded. + */ + released = akerr_release_error(e); + (void)released; + return aksl_last_status; +} + +/* ---------------------------------------------------------------------- */ +/* Assertions */ +/* ---------------------------------------------------------------------- */ + +#define AKSL_CHECK(cond) \ + do { \ + if ( !(cond) ) { \ + fprintf(stderr, " CHECK FAILED: %s at %s:%d\n", \ + #cond, __FILE__, __LINE__); \ + return 1; \ + } \ + } while ( 0 ) + +/* + * Run an akerr_ErrorContext *-returning expression and assert on its status. + * The context is always released, including on the failure path, so a failing + * assertion does not also corrupt the pool-leak checks that follow it. + */ +#define AKSL_CHECK_STATUS(__expr, __expected) \ + do { \ + int __st = aksl_take(__expr); \ + if ( __st != (__expected) ) { \ + fprintf(stderr, \ + " CHECK FAILED: %s\n" \ + " got %d (%s) \"%s\"\n" \ + " expected %d (%s)\n" \ + " at %s:%d\n", \ + #__expr, \ + __st, akerr_name_for_status(__st, NULL), \ + aksl_last_message, \ + (__expected), \ + akerr_name_for_status((__expected), NULL), \ + __FILE__, __LINE__); \ + return 1; \ + } \ + } while ( 0 ) + +#define AKSL_CHECK_OK(__expr) AKSL_CHECK_STATUS(__expr, 0) + +#define AKSL_CHECK_CONTAINS(needle) \ + AKSL_CHECK(strstr(aksl_capture_buf, (needle)) != NULL) + +#define AKSL_CHECK_NOT_CONTAINS(needle) \ + AKSL_CHECK(strstr(aksl_capture_buf, (needle)) == NULL) + +/* Assert on the message of the context most recently taken. */ +#define AKSL_CHECK_MSG_CONTAINS(needle) \ + AKSL_CHECK(strstr(aksl_last_message, (needle)) != NULL) + +/* ---------------------------------------------------------------------- */ +/* Test driver */ +/* ---------------------------------------------------------------------- */ + +/* + * Run one test function, report it, and tally failures. Also asserts that the + * test left the error pool as it found it -- a wrapper that fails to release a + * context is a bug in the library, not just in the test. + */ +#define AKSL_RUN(__failures, __fn) \ + do { \ + int __before = aksl_slots_in_use(); \ + int __r = __fn(); \ + int __after = aksl_slots_in_use(); \ + if ( __r != 0 ) { \ + fprintf(stderr, "FAIL %s\n", #__fn); \ + (__failures)++; \ + } else if ( __after != __before ) { \ + fprintf(stderr, \ + "FAIL %s (leaked %d error pool slot(s))\n", \ + #__fn, __after - __before); \ + (__failures)++; \ + } else { \ + fprintf(stderr, "ok %s\n", #__fn); \ + } \ + } while ( 0 ) + +#define AKSL_REPORT(__failures) \ + do { \ + fprintf(stderr, "%s: %d failure(s)\n", __FILE__, (__failures)); \ + return (__failures) == 0 ? 0 : 1; \ + } while ( 0 ) + +#endif // AKSL_TEST_CAPTURE_H diff --git a/tests/test_linkedlist.c b/tests/test_linkedlist.c index bc80c0c..0d24f72 100644 --- a/tests/test_linkedlist.c +++ b/tests/test_linkedlist.c @@ -1,75 +1,319 @@ -#include -#include +/* + * Linked-list behaviour that the library gets right today. + * + * The two confirmed list defects (TODO.md 2.1.1 aksl_list_append truncating the + * chain, and 2.1.2 aksl_list_iterate skipping the head) are asserted separately + * in tests/test_list_append_chain.c and tests/test_list_iterate_head.c, which + * are registered as known-failing. Everything in this file must pass. + * + * Note that the list-shape assertions here build their lists by hand rather + * than with aksl_list_append: append cannot be trusted to produce a chain + * longer than two nodes until 2.1.1 is fixed, and using it would make these + * tests fail for a reason that has nothing to do with what they are checking. + */ +#include "aksl_capture.h" -// This iterator does nothing but print the node names it is visiting -akerr_ErrorContext AKERR_NOIGNORE *myiter(aksl_ListNode *node, void *data) +#define MAX_VISITS 16 + +typedef struct VisitLog { - int count = 0; + int count; + aksl_ListNode *seen[MAX_VISITS]; + int break_at; /* visit index to raise ITERATOR_BREAK on, or -1 */ + int fail_at; /* visit index to raise AKERR_VALUE on, or -1 */ +} VisitLog; + +static void visitlog_init(VisitLog *log) +{ + memset((void *)log, 0x00, sizeof(VisitLog)); + log->break_at = -1; + log->fail_at = -1; +} + +static akerr_ErrorContext AKERR_NOIGNORE *record_visit(aksl_ListNode *node, void *data) +{ + VisitLog *log = NULL; + int idx = 0; + PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node"); - FAIL_ZERO_RETURN(e, node->data, AKERR_NULLPOINTER, "node->data"); - PASS(e, aksl_fprintf(&count, stderr, "Visiting node : %s\n", node->data)); + FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data"); + log = (VisitLog *)data; + idx = log->count; + if ( idx < MAX_VISITS ) { + log->seen[idx] = node; + } + log->count += 1; + if ( log->fail_at == idx ) { + FAIL_RETURN(e, AKERR_VALUE, "iterator failed at visit %d", idx); + } + if ( log->break_at == idx ) { + FAIL_RETURN(e, AKERR_ITERATOR_BREAK, "stop at visit %d", idx); + } SUCCEED_RETURN(e); } -// This iterator function exits early once the index in `(int *)data` is reached -akerr_ErrorContext AKERR_NOIGNORE *myiter_earlyhalt(aksl_ListNode *node, void *data) +/* ---------------------------------------------------------------------- */ +/* aksl_list_append */ +/* ---------------------------------------------------------------------- */ + +static int test_append_single_node(void) { - int *idx; - int count = 0; - PREPARE_ERROR(e); - FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node"); - FAIL_ZERO_RETURN(e, node->data, AKERR_NULLPOINTER, "node->data"); - FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data"); - idx = (int *)data; - if ( *idx == 1 ) { - // This exception is eaten by the iterator, we will never see it - FAIL_RETURN(e, AKERR_ITERATOR_BREAK, "stop"); - } - *idx += 1; - SUCCEED_RETURN(e); + aksl_ListNode head; + aksl_ListNode tail; + + memset((void *)&head, 0x00, sizeof(head)); + memset((void *)&tail, 0x00, sizeof(tail)); + + AKSL_CHECK_OK(aksl_list_append(&head, &tail)); + AKSL_CHECK(head.next == &tail); + AKSL_CHECK(head.prev == NULL); + AKSL_CHECK(tail.prev == &head); + AKSL_CHECK(tail.next == NULL); + return 0; +} + +static int test_append_null_arguments(void) +{ + aksl_ListNode node; + + memset((void *)&node, 0x00, sizeof(node)); + + AKSL_CHECK_STATUS(aksl_list_append(NULL, &node), AKERR_NULLPOINTER); + AKSL_CHECK_STATUS(aksl_list_append(&node, NULL), AKERR_NULLPOINTER); + return 0; +} + +static int test_append_detects_self_cycle(void) +{ + aksl_ListNode head; + aksl_ListNode node; + + memset((void *)&head, 0x00, sizeof(head)); + memset((void *)&node, 0x00, sizeof(node)); + head.next = &head; + + AKSL_CHECK_STATUS(aksl_list_append(&head, &node), AKERR_CIRCULAR_REFERENCE); + return 0; +} + +static int test_append_detects_two_node_cycle(void) +{ + aksl_ListNode a; + aksl_ListNode b; + aksl_ListNode node; + + memset((void *)&a, 0x00, sizeof(a)); + memset((void *)&b, 0x00, sizeof(b)); + memset((void *)&node, 0x00, sizeof(node)); + a.next = &b; + b.prev = &a; + b.next = &a; + + AKSL_CHECK_STATUS(aksl_list_append(&a, &node), AKERR_CIRCULAR_REFERENCE); + return 0; +} + +/* ---------------------------------------------------------------------- */ +/* aksl_list_iterate */ +/* ---------------------------------------------------------------------- */ + +static int test_iterate_null_arguments(void) +{ + aksl_ListNode node; + VisitLog log; + + memset((void *)&node, 0x00, sizeof(node)); + visitlog_init(&log); + + AKSL_CHECK_STATUS(aksl_list_iterate(NULL, &record_visit, &log), AKERR_NULLPOINTER); + AKSL_CHECK_STATUS(aksl_list_iterate(&node, NULL, &log), AKERR_NULLPOINTER); + AKSL_CHECK(log.count == 0); + return 0; +} + +static int test_iterate_single_node(void) +{ + aksl_ListNode node; + VisitLog log; + + memset((void *)&node, 0x00, sizeof(node)); + visitlog_init(&log); + + AKSL_CHECK_OK(aksl_list_iterate(&node, &record_visit, &log)); + AKSL_CHECK(log.count == 1); + AKSL_CHECK(log.seen[0] == &node); + return 0; +} + +static int test_iterate_detects_self_cycle(void) +{ + aksl_ListNode head; + VisitLog log; + + memset((void *)&head, 0x00, sizeof(head)); + head.next = &head; + visitlog_init(&log); + + AKSL_CHECK_STATUS(aksl_list_iterate(&head, &record_visit, &log), + AKERR_CIRCULAR_REFERENCE); + return 0; +} + +static int test_iterate_detects_two_node_cycle(void) +{ + aksl_ListNode a; + aksl_ListNode b; + VisitLog log; + + memset((void *)&a, 0x00, sizeof(a)); + memset((void *)&b, 0x00, sizeof(b)); + a.next = &b; + b.prev = &a; + b.next = &a; + visitlog_init(&log); + + AKSL_CHECK_STATUS(aksl_list_iterate(&a, &record_visit, &log), + AKERR_CIRCULAR_REFERENCE); + return 0; +} + +/* An error other than ITERATOR_BREAK must come back out of the iteration with + * its status and message intact. */ +static int test_iterate_propagates_callback_error(void) +{ + aksl_ListNode node; + VisitLog log; + + memset((void *)&node, 0x00, sizeof(node)); + visitlog_init(&log); + log.fail_at = 0; + + AKSL_CHECK_STATUS(aksl_list_iterate(&node, &record_visit, &log), AKERR_VALUE); + AKSL_CHECK_MSG_CONTAINS("iterator failed at visit 0"); + AKSL_CHECK(log.count == 1); + return 0; +} + +/* ITERATOR_BREAK is a control signal, not a failure: the caller sees success. */ +static int test_iterate_break_is_not_an_error(void) +{ + aksl_ListNode node; + VisitLog log; + + memset((void *)&node, 0x00, sizeof(node)); + visitlog_init(&log); + log.break_at = 0; + + AKSL_CHECK_OK(aksl_list_iterate(&node, &record_visit, &log)); + AKSL_CHECK(log.count == 1); + return 0; +} + +/* ---------------------------------------------------------------------- */ +/* aksl_list_pop */ +/* ---------------------------------------------------------------------- */ + +static int test_pop_middle_node(void) +{ + aksl_ListNode a; + aksl_ListNode b; + aksl_ListNode c; + + memset((void *)&a, 0x00, sizeof(a)); + memset((void *)&b, 0x00, sizeof(b)); + memset((void *)&c, 0x00, sizeof(c)); + a.next = &b; + b.prev = &a; + b.next = &c; + c.prev = &b; + + AKSL_CHECK_OK(aksl_list_pop(&b)); + AKSL_CHECK(a.next == &c); + AKSL_CHECK(c.prev == &a); + AKSL_CHECK(b.next == NULL); + AKSL_CHECK(b.prev == NULL); + return 0; +} + +static int test_pop_head_node(void) +{ + aksl_ListNode a; + aksl_ListNode b; + + memset((void *)&a, 0x00, sizeof(a)); + memset((void *)&b, 0x00, sizeof(b)); + a.next = &b; + b.prev = &a; + + AKSL_CHECK_OK(aksl_list_pop(&a)); + AKSL_CHECK(b.prev == NULL); + AKSL_CHECK(b.next == NULL); + AKSL_CHECK(a.next == NULL); + AKSL_CHECK(a.prev == NULL); + return 0; +} + +static int test_pop_tail_node(void) +{ + aksl_ListNode a; + aksl_ListNode b; + + memset((void *)&a, 0x00, sizeof(a)); + memset((void *)&b, 0x00, sizeof(b)); + a.next = &b; + b.prev = &a; + + AKSL_CHECK_OK(aksl_list_pop(&b)); + AKSL_CHECK(a.next == NULL); + AKSL_CHECK(a.prev == NULL); + AKSL_CHECK(b.next == NULL); + AKSL_CHECK(b.prev == NULL); + return 0; +} + +static int test_pop_only_node(void) +{ + aksl_ListNode a; + + memset((void *)&a, 0x00, sizeof(a)); + + AKSL_CHECK_OK(aksl_list_pop(&a)); + AKSL_CHECK(a.next == NULL); + AKSL_CHECK(a.prev == NULL); + return 0; +} + +static int test_pop_null_argument(void) +{ + AKSL_CHECK_STATUS(aksl_list_pop(NULL), AKERR_NULLPOINTER); + return 0; } int main(void) { - PREPARE_ERROR(e); - int idx = 0; - int count = 0; - aksl_ListNode mylist; - aksl_ListNode node1; - aksl_ListNode node2; + int failures = 0; - ATTEMPT { - memset((void *)&mylist, 0x00, sizeof(aksl_ListNode)); - memset((void *)&node1, 0x00, sizeof(aksl_ListNode)); - memset((void *)&node2, 0x00, sizeof(aksl_ListNode)); + akerr_init(); - mylist.data = "Root"; - - node1.data = "Node 1"; - CATCH(e, aksl_list_append(&mylist, &node1)); - - node2.data = "Node 2"; - CATCH(e, aksl_list_append(&mylist, &node2)); + AKSL_RUN(failures, test_append_single_node); + AKSL_RUN(failures, test_append_null_arguments); + AKSL_RUN(failures, test_append_detects_self_cycle); + AKSL_RUN(failures, test_append_detects_two_node_cycle); - // Iterate over all nodes in the list using the myiter() function - CATCH(e, aksl_list_iterate(&mylist, &myiter, NULL)); + AKSL_RUN(failures, test_iterate_null_arguments); + AKSL_RUN(failures, test_iterate_single_node); + AKSL_RUN(failures, test_iterate_detects_self_cycle); + AKSL_RUN(failures, test_iterate_detects_two_node_cycle); + AKSL_RUN(failures, test_iterate_propagates_callback_error); + AKSL_RUN(failures, test_iterate_break_is_not_an_error); - // Iterate over up to the first 2 nodes in the list and then exit early - idx = 0; - CATCH(e, aksl_list_iterate(&mylist, &myiter_earlyhalt, &idx)); - CATCH(e, aksl_fprintf(&count, stderr, "Iterator exited early at index %d\n", idx)); + AKSL_RUN(failures, test_pop_middle_node); + AKSL_RUN(failures, test_pop_head_node); + AKSL_RUN(failures, test_pop_tail_node); + AKSL_RUN(failures, test_pop_only_node); + AKSL_RUN(failures, test_pop_null_argument); - // Break the list with a circular reference, and iterate it again - node2.next = &mylist; - CATCH(e, aksl_list_iterate(&mylist, &myiter, NULL)); - - } CLEANUP { - } PROCESS(e) { - } HANDLE(e, AKERR_CIRCULAR_REFERENCE) { - fprintf(stderr, "Circular reference error caught\n"); - } FINISH_NORETURN(e); - - return 0; + AKSL_REPORT(failures); } diff --git a/tests/test_list_append_chain.c b/tests/test_list_append_chain.c new file mode 100644 index 0000000..0e706ae --- /dev/null +++ b/tests/test_list_append_chain.c @@ -0,0 +1,59 @@ +/* + * KNOWN FAILING -- TODO.md section 2.1.1 + * + * aksl_list_append conflates Floyd cycle detection with finding the tail: the + * `tail` cursor is assigned from `slow` *before* `slow` advances, so it tracks + * the node behind the list midpoint rather than the last node. Appending to any + * list of two or more nodes therefore overwrites an interior link and silently + * drops every node after it. + * + * Appending n1..n4 to n0 produces the chain "n0 -> n4"; this test asserts the + * correct "n0 -> n1 -> n2 -> n3 -> n4" and so fails until append is fixed. + * It is registered in AKSL_KNOWN_FAILING_TESTS, which marks it WILL_FAIL; when + * the fix lands, CTest will report it as unexpectedly passing, which is the + * signal to move it into AKSL_TESTS. + */ + +#include "aksl_capture.h" + +#define CHAIN_LEN 5 + +static int test_append_builds_full_chain(void) +{ + aksl_ListNode node[CHAIN_LEN]; + aksl_ListNode *walk = NULL; + int i = 0; + + memset((void *)node, 0x00, sizeof(node)); + + for ( i = 1; i < CHAIN_LEN; i++ ) { + AKSL_CHECK_OK(aksl_list_append(&node[0], &node[i])); + } + + /* Forward links, head to tail. */ + walk = &node[0]; + for ( i = 0; i < CHAIN_LEN; i++ ) { + AKSL_CHECK(walk == &node[i]); + walk = walk->next; + } + AKSL_CHECK(walk == NULL); + + /* Back links, tail to head. */ + walk = &node[CHAIN_LEN - 1]; + for ( i = CHAIN_LEN - 1; i >= 0; i-- ) { + AKSL_CHECK(walk == &node[i]); + walk = walk->prev; + } + AKSL_CHECK(walk == NULL); + + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + AKSL_RUN(failures, test_append_builds_full_chain); + AKSL_REPORT(failures); +} diff --git a/tests/test_list_iterate_head.c b/tests/test_list_iterate_head.c new file mode 100644 index 0000000..7a7cb62 --- /dev/null +++ b/tests/test_list_iterate_head.c @@ -0,0 +1,71 @@ +/* + * KNOWN FAILING -- TODO.md section 2.1.2 + * + * aksl_list_iterate runs a Floyd cycle check that leaves `slow` sitting at the + * list midpoint, and then starts the visiting loop from `slow` instead of from + * `list`. Every node before the midpoint -- including the head -- is never + * passed to the callback. + * + * The list here is built by hand rather than with aksl_list_append so that this + * test fails only for the iterate defect, not for the separate append defect in + * TODO.md 2.1.1. + * + * Registered in AKSL_KNOWN_FAILING_TESTS (WILL_FAIL). When iterate is fixed, + * CTest reports this as unexpectedly passing -- move it into AKSL_TESTS then. + */ + +#include "aksl_capture.h" + +#define CHAIN_LEN 3 + +typedef struct VisitLog +{ + int count; + aksl_ListNode *seen[CHAIN_LEN]; +} VisitLog; + +static akerr_ErrorContext AKERR_NOIGNORE *record_visit(aksl_ListNode *node, void *data) +{ + VisitLog *log = NULL; + + PREPARE_ERROR(e); + FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node"); + FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data"); + log = (VisitLog *)data; + if ( log->count < CHAIN_LEN ) { + log->seen[log->count] = node; + } + log->count += 1; + SUCCEED_RETURN(e); +} + +static int test_iterate_visits_every_node_from_the_head(void) +{ + aksl_ListNode node[CHAIN_LEN]; + VisitLog log; + int i = 0; + + memset((void *)node, 0x00, sizeof(node)); + memset((void *)&log, 0x00, sizeof(log)); + + for ( i = 1; i < CHAIN_LEN; i++ ) { + node[i - 1].next = &node[i]; + node[i].prev = &node[i - 1]; + } + + AKSL_CHECK_OK(aksl_list_iterate(&node[0], &record_visit, &log)); + AKSL_CHECK(log.count == CHAIN_LEN); + for ( i = 0; i < CHAIN_LEN; i++ ) { + AKSL_CHECK(log.seen[i] == &node[i]); + } + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + AKSL_RUN(failures, test_iterate_visits_every_node_from_the_head); + AKSL_REPORT(failures); +} diff --git a/tests/test_tree.c b/tests/test_tree.c index 5ebb874..6147fe4 100644 --- a/tests/test_tree.c +++ b/tests/test_tree.c @@ -1,7 +1,23 @@ -#include -#include +/* + * Depth-first tree search. + * + * Previously this test shared one TreeSearchParams across all three searches + * without resetting it, so `steps` accumulated (7, then 14, then 21) and the + * second assertion failed -- `tree` was a red test on every run. Each search + * now gets its own params. + * + * Caveat worth knowing when reading the step counts below: the value is hidden + * in tree[6], which is the last node visited in pre-, in- and post-order alike, + * so "7 steps" holds for all three orders and does not actually distinguish + * them -- nor does it prove that AKERR_ITERATOR_BREAK stopped anything (it does + * not; see tests/test_tree_iterate_break.c and TODO.md 2.1.3). Visit-order + * assertions that tell the three traversals apart are TODO.md section 1.8. + */ -#define MAX_LEAVES 7 +#include "aksl_capture.h" + +#define MAX_LEAVES 7 +#define HIDDEN_VALUE ((void *)17336) typedef struct TreeSearchParams { @@ -10,11 +26,10 @@ typedef struct TreeSearchParams aksl_TreeNode *node; } TreeSearchParams; -// This iterator does nothing but print the node names it is visiting -akerr_ErrorContext AKERR_NOIGNORE *myiter(aksl_TreeNode *node, void *data) +static akerr_ErrorContext AKERR_NOIGNORE *find_value(aksl_TreeNode *node, void *data) { - int count = 0; TreeSearchParams *parms = NULL; + PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node"); FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data"); @@ -27,70 +42,72 @@ akerr_ErrorContext AKERR_NOIGNORE *myiter(aksl_TreeNode *node, void *data) SUCCEED_RETURN(e); } +/* + * Build the 3-level tree used by every case here, with the search value hidden + * in the bottom-right leaf. + * + * LEFT RIGHT + * TREE[0] + * +--------^^---------+ + * | | + * TREE[1] TREE[2] + * +---^^---+ +---^^---+ + * | | | | + *TREE[3] TREE[4] TREE[5] TREE[6] + */ +static void build_tree(aksl_TreeNode *tree, TreeSearchParams *parms) +{ + memset((void *)tree, 0x00, sizeof(aksl_TreeNode) * MAX_LEAVES); + tree[0].left = &tree[1]; + tree[0].right = &tree[2]; + tree[1].left = &tree[3]; + tree[1].right = &tree[4]; + tree[2].left = &tree[5]; + tree[2].right = &tree[6]; + tree[6].leaf = HIDDEN_VALUE; + + memset((void *)parms, 0x00, sizeof(TreeSearchParams)); + parms->value = HIDDEN_VALUE; +} + +static int search_finds_hidden_value(uint8_t searchmode) +{ + aksl_TreeNode tree[MAX_LEAVES]; + TreeSearchParams parms; + + build_tree(tree, &parms); + + AKSL_CHECK_OK(aksl_tree_iterate(&tree[0], &find_value, NULL, NULL, + searchmode, &parms, NULL)); + AKSL_CHECK(parms.node == &tree[6]); + AKSL_CHECK(parms.steps == MAX_LEAVES); + return 0; +} + +static int test_dfs_preorder(void) +{ + return search_finds_hidden_value(AKSL_TREE_SEARCH_DFS_PREORDER); +} + +static int test_dfs_inorder(void) +{ + return search_finds_hidden_value(AKSL_TREE_SEARCH_DFS_INORDER); +} + +static int test_dfs_postorder(void) +{ + return search_finds_hidden_value(AKSL_TREE_SEARCH_DFS_POSTORDER); +} + int main(void) { - PREPARE_ERROR(e); - aksl_TreeNode tree[MAX_LEAVES]; - TreeSearchParams parms = { - .value = (void *)17336, - .steps = 0, - .node = NULL - }; + int failures = 0; - ATTEMPT { - memset((void *)&tree, 0x00, sizeof(aksl_TreeNode) * MAX_LEAVES); - /* - * Here we build a 3 level tree - * - * LEFT RIGHT - * TREE[0] - * +--------^^---------+ - * | | - * TREE[1] TREE[2] - * +---^^---+ +---^^---+ - * | | | | - *TREE[3] TREE[4] TREE[5] TREE[6] - */ - tree[0].left = &tree[1]; - tree[0].right = &tree[2]; - tree[1].left = &tree[3]; - tree[1].right = &tree[4]; - tree[2].left = &tree[5]; - tree[2].right = &tree[6]; + akerr_init(); - // Hide a value in tree[6] - tree[6].leaf = (void *)17336; + AKSL_RUN(failures, test_dfs_preorder); + AKSL_RUN(failures, test_dfs_inorder); + AKSL_RUN(failures, test_dfs_postorder); - // Search for the value 17336 using DFS_PREORDER - CATCH(e, aksl_tree_iterate(&tree[0], &myiter, NULL, NULL, AKSL_TREE_SEARCH_DFS_PREORDER, &parms, NULL)); - if ( parms.node != &tree[6] ) { - FAIL_BREAK(e, AKERR_API, "DFS_PREORDER_SEARCH didn't find the node"); - } - if ( parms.steps != 7 ) { - FAIL_BREAK(e, AKERR_API, "DFS_PREORDER_SEARCH should've found the node in 7 steps, instead took %d", parms.steps); - } - - // Search for the value 17336 using DFS_INORDER - CATCH(e, aksl_tree_iterate(&tree[0], &myiter, NULL, NULL, AKSL_TREE_SEARCH_DFS_INORDER, &parms, NULL)); - if ( parms.node != &tree[6] ) { - FAIL_BREAK(e, AKERR_API, "DFS_INORDER_SEARCH didn't find the node"); - } - if ( parms.steps != 7 ) { - FAIL_BREAK(e, AKERR_API, "DFS_INORDER_SEARCH should've found the node in 7 steps, instead took %d", parms.steps); - } - - // Search for the value 17336 using DFS_PREORDER - CATCH(e, aksl_tree_iterate(&tree[0], &myiter, NULL, NULL, AKSL_TREE_SEARCH_DFS_POSTORDER, &parms, NULL)); - if ( parms.node != &tree[6] ) { - FAIL_BREAK(e, AKERR_API, "DFS_POSTORDER_SEARCH didn't find the node"); - } - if ( parms.steps != 7 ) { - FAIL_BREAK(e, AKERR_API, "DFS_POSTORDER_SEARCH should've found the node in 7 steps, instead took %d", parms.steps); - } - - } CLEANUP { - } PROCESS(e) { - } FINISH_NORETURN(e); - - return 0; + AKSL_REPORT(failures); } diff --git a/tests/test_tree_iterate_break.c b/tests/test_tree_iterate_break.c new file mode 100644 index 0000000..6b811cb --- /dev/null +++ b/tests/test_tree_iterate_break.c @@ -0,0 +1,89 @@ +/* + * KNOWN FAILING -- TODO.md section 2.1.3 + * + * AKERR_ITERATOR_BREAK does not abort a tree traversal. aksl_tree_iterate + * recurses into itself, and the frame in which the callback raises the break + * handles it in its own PROCESS/HANDLE(e, AKERR_ITERATOR_BREAK) block and + * returns *success*. The parent frame's PASS therefore sees no error at all and + * carries straight on into the sibling subtree, so the traversal runs to + * completion. + * + * The 7-node tree below is walked pre-order (0, 1, 3, 4, 2, 5, 6) with the + * callback breaking on tree[3], the third node visited. A working break stops + * the walk at 3 visits; today the callback is invoked all 7 times. + * + * tests/test_tree.c cannot catch this: it hides its value in tree[6], which is + * the last node visited in all three depth-first orders, so a break that never + * fires is indistinguishable from one that fires on the final node. + * + * Registered in AKSL_KNOWN_FAILING_TESTS (WILL_FAIL). When the break semantics + * are fixed, CTest reports this as unexpectedly passing -- move it into + * AKSL_TESTS then. + */ + +#include "aksl_capture.h" + +#define MAX_LEAVES 7 + +typedef struct BreakParams +{ + aksl_TreeNode *stop_at; + int visits; +} BreakParams; + +static akerr_ErrorContext AKERR_NOIGNORE *break_at_node(aksl_TreeNode *node, void *data) +{ + BreakParams *parms = NULL; + + PREPARE_ERROR(e); + FAIL_ZERO_RETURN(e, node, AKERR_NULLPOINTER, "node"); + FAIL_ZERO_RETURN(e, data, AKERR_NULLPOINTER, "data"); + parms = (BreakParams *)data; + parms->visits += 1; + if ( node == parms->stop_at ) { + FAIL_RETURN(e, AKERR_ITERATOR_BREAK, "stop"); + } + SUCCEED_RETURN(e); +} + +static int test_break_aborts_the_whole_traversal(void) +{ + aksl_TreeNode tree[MAX_LEAVES]; + BreakParams parms; + + memset((void *)tree, 0x00, sizeof(tree)); + memset((void *)&parms, 0x00, sizeof(parms)); + + /* + * TREE[0] + * +--------^^---------+ + * | | + * TREE[1] TREE[2] + * +---^^---+ +---^^---+ + * | | | | + *TREE[3] TREE[4] TREE[5] TREE[6] + */ + tree[0].left = &tree[1]; + tree[0].right = &tree[2]; + tree[1].left = &tree[3]; + tree[1].right = &tree[4]; + tree[2].left = &tree[5]; + tree[2].right = &tree[6]; + + parms.stop_at = &tree[3]; + + AKSL_CHECK_OK(aksl_tree_iterate(&tree[0], &break_at_node, NULL, NULL, + AKSL_TREE_SEARCH_DFS_PREORDER, &parms, NULL)); + /* Pre-order visits 0, 1, 3 -- the break on tree[3] must stop the walk there. */ + AKSL_CHECK(parms.visits == 3); + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + AKSL_RUN(failures, test_break_aborts_the_whole_traversal); + AKSL_REPORT(failures); +}