From 437da2960b60da6517eae10dc061d9caae957666 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Thu, 30 Jul 2026 02:07:08 -0400 Subject: [PATCH] Test the libc wrappers: 52% -> 99% line coverage Every wrapper outside the list and tree code was untested. Six new test files close that, following the plan already written in TODO.md 1.2-1.6: test_stream.c fopen/fread/fwrite/fclose -- happy paths, the round trip, AKERR_EOF on a short read, AKERR_IO on a stream opened in the wrong mode, ENOENT, and the NULL guards test_format.c printf/fprintf/sprintf -- text *and* count asserted (stdout is pointed at a temp file to check aksl_printf), all eight NULL guards, EBADF on a read-only stream, and 512 variadic calls in a loop as sanitizer cover for the missing va_end test_convert.c ato{i,l,ll,f} happy paths, negatives, leading whitespace, NULL guards test_path.c realpath on a file and on a symlink, both compared against realpath(3) since TMPDIR may itself be a link; ENOENT, ENOTDIR, NULL path test_strhash.c djb2 known-answer vectors, len == 0, embedded NUL, stability, NULL guards test_convert_strict.c known-failing (2.1.5): the AKERR_VALUE / ERANGE contract the ato* family cannot express today test_tree.c gains the BFS AKERR_NOT_IMPLEMENTED contract, NULL arguments, and a callback error that is not AKERR_ITERATOR_BREAK propagating out. Tests deliberately say nothing about behaviour TODO.md records as defective -- unchecked ptr/mode/resolved_path, short transfers reported as success, *count left at -1, the djb2 sign extension -- so the eventual fix does not have to come with a test rewrite. Each failure case in test_path.c passes a zeroed buffer, because the wrapper's own error path formats resolved_path with %s (2.1.6). aksl_capture.h gains aksl_temp_file() with an atexit unlink backstop. Without it every test that fails before its own unlink leaves temp files behind -- which is the normal case for a known-failing test, and happens 173 times over in a mutation run. Coverage on src/stdlib.c: 52.0% -> 99.0% of lines (200/202), 23.6% -> 51.0% of branches, 8/21 -> 21/21 functions. The two uncovered lines are both `} HANDLE(e, AKERR_ITERATOR_BREAK) {`, where the macro starts with the `break;` of PROCESS's `case 0:` arm -- reachable only via a non-NULL error context whose status is zero, the pathology 2.2.1 exists to remove. Mutation score on src/stdlib.c: 46.8% -> 89.6% (155/173 killed). CI, the pre-push hook and the docs ratchet from 40 to 80 accordingly, and the 18 survivors are grouped by cause in TODO.md and README.md. A new CI coverage job gates at 90% lines / 45% branches. Verified: ctest --test-dir build # 12/12 ctest --test-dir build-asan # 12/12 under ASan + UBSan ctest --test-dir build-coverage # 14/14, report attached ctest --test-dir build -j8 --repeat until-fail:3 gcc -Wall -Wextra -c on all nine test files # no warnings python3 scripts/mutation_test.py --target src/stdlib.c # 89.6% No temp files left in /tmp after any of the above. Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/ci.yaml | 44 +++++-- .githooks/pre-push | 2 +- AGENTS.md | 8 ++ CMakeLists.txt | 6 + README.md | 52 +++++++-- TODO.md | 130 ++++++++++++--------- tests/aksl_capture.h | 66 +++++++++++ tests/test_convert.c | 128 +++++++++++++++++++++ tests/test_convert_strict.c | 75 ++++++++++++ tests/test_format.c | 224 ++++++++++++++++++++++++++++++++++++ tests/test_path.c | 116 +++++++++++++++++++ tests/test_stream.c | 188 ++++++++++++++++++++++++++++++ tests/test_strhash.c | 103 +++++++++++++++++ tests/test_tree.c | 69 +++++++++++ 14 files changed, 1138 insertions(+), 73 deletions(-) create mode 100644 tests/test_convert.c create mode 100644 tests/test_convert_strict.c create mode 100644 tests/test_format.c create mode 100644 tests/test_path.c create mode 100644 tests/test_stream.c create mode 100644 tests/test_strhash.c diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d3853b1..0babd83 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -31,6 +31,35 @@ jobs: ctest --test-dir build --output-on-failure - run: echo "๐Ÿ This job's status is ${{ job.status }}." + coverage: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + with: + submodules: recursive + - name: dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y cmake gcc python3 + # scripts/coverage.py needs nothing but python3 and gcc's own gcov, so + # there is no lcov/gcovr to install here. + # + # The gate is a ratchet, not a target: src/stdlib.c is at 99.0% of lines + # and 51.0% of branches, so 90/45 fails on a real regression (a test + # deleted, or new untested code added) without tripping over rounding. + # The report is printed either way -- the uncovered lines it lists are the + # missing tests. + - name: coverage + run: | + cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \ + -DAKSL_COVERAGE_THRESHOLD=90 \ + -DAKSL_COVERAGE_BRANCH_THRESHOLD=45 + cmake --build build-coverage + ctest --test-dir build-coverage --output-on-failure + cat build-coverage/coverage-summary.txt + - run: echo "๐Ÿ This job's status is ${{ job.status }}." + mutation_test: runs-on: ubuntu-latest steps: @@ -48,19 +77,18 @@ jobs: # confirm the suite fails. Gated on src/stdlib.c (fast, deterministic); # run the full default target locally for the macro header as well. # - # The threshold is a ratchet, not a quality bar. The score on the section - # 1.0 suite is 46.8% (81/173 killed): the list and tree functions are - # covered, and everything else in src/stdlib.c -- the printf family, the - # ato* family, realpath, the memory and stream wrappers -- has no tests - # yet, so its mutants survive. 40 leaves headroom for the runner while - # still failing on a real regression (tests deleted, or new untested code - # added). Raise it as TODO.md sections 1.1-1.9 land. + # The threshold is a ratchet, not a quality bar. The score is 89.6% + # (155/173 killed) now that TODO.md sections 1.2-1.6 have tests; it was + # 46.8% when only the list and tree functions were covered. 80 leaves + # headroom for the runner while still failing on a real regression (tests + # deleted, or new untested code added). The 18 survivors are listed in the + # published report -- each one is a missing assertion. - name: mutation testing run: | python3 scripts/mutation_test.py \ --target src/stdlib.c \ --junit mutation-junit.xml \ - --threshold 40 + --threshold 80 # Publish even when the threshold gate fails, so survivors are visible -- # each one is a missing test. Display-only (fail_on_failure: false); the # --threshold above is the gate. annotate_only avoids the Checks API 404 diff --git a/.githooks/pre-push b/.githooks/pre-push index c47d23b..e71e5ad 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -29,7 +29,7 @@ set -u # Keep in sync with the --threshold in .gitea/workflows/ci.yaml, so a push that # would fail CI fails here first. -MUTATION_THRESHOLD="${AKSL_MUTATION_THRESHOLD:-40}" +MUTATION_THRESHOLD="${AKSL_MUTATION_THRESHOLD:-80}" ZERO_SHA=0000000000000000000000000000000000000000 diff --git a/AGENTS.md b/AGENTS.md index d39c19b..760ed5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,14 @@ right list in `CMakeLists.txt`. `AKSL_TESTS` must exit zero. `AKSL_KNOWN_FAILING_TESTS` assert documented defects from `TODO.md`; when one starts unexpectedly passing, move it into `AKSL_TESTS` with the fix. +`src/stdlib.c` is at 99.0% line coverage and CI gates it at 90 (line) / 45 +(branch), so new code needs tests in the same commit. Run +`cmake --build build-coverage --target coverage` and check the uncovered-line +listing before proposing a change. Tests for behaviour that `TODO.md` records as +defective belong in `AKSL_KNOWN_FAILING_TESTS` asserting the *correct* contract โ€” +do not pin current-but-wrong behaviour in `AKSL_TESTS`, since that turns the +eventual fix into a test failure. + ## Commit & Pull Request Guidelines Recent commits use short imperative summaries, for example `Add memory wrapper diff --git a/CMakeLists.txt b/CMakeLists.txt index b873e86..0c7ec61 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -175,8 +175,13 @@ install(FILES # "unexpectedly passed" -- that is the cue to move # it up into AKSL_TESTS. set(AKSL_TESTS + convert + format linkedlist memory + path + stream + strhash tree ) @@ -184,6 +189,7 @@ set(AKSL_WILL_FAIL_TESTS ) set(AKSL_KNOWN_FAILING_TESTS + convert_strict # TODO.md 2.1.5 -- ato* cannot report a bad conversion 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 diff --git a/README.md b/README.md index 62b5c86..a7fd78c 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,13 @@ ctest --test-dir build --output-on-failure Tests live one per file in `tests/test_.c` and share the helpers in `tests/aksl_capture.h` โ€” `AKSL_CHECK()` for plain assertions (unlike `assert()` it survives `-DNDEBUG`), `AKSL_CHECK_STATUS(call, expected)` to run a wrapper and -assert on the status it returns, and an `AKSL_RUN()` driver that additionally -fails any test which leaks a slot from libakerror's error pool. +assert on the status it returns, `aksl_temp_file()` for tests that need a real +file to work on, and an `AKSL_RUN()` driver that additionally fails any test which +leaks a slot from libakerror's error pool. + +One file per area of the API: `convert` (the `ato*` family), `format` (the +`printf` family), `stream` (`fopen`/`fread`/`fwrite`/`fclose`), `path` +(`aksl_realpath`), `strhash`, `memory`, `linkedlist` and `tree`. To add a test, drop `tests/test_mything.c` in place and add `mything` to `AKSL_TESTS` in `CMakeLists.txt`. @@ -118,7 +123,7 @@ scripts/coverage.py --build build-coverage # report on disk scripts/coverage.py --build build-coverage --summary-only # totals only scripts/coverage.py --build build-coverage --include tests # coverage of the tests themselves scripts/coverage.py --build build-coverage --run-tests # reset, run ctest, report -scripts/coverage.py --build build-coverage --threshold 50 --branch-threshold 25 +scripts/coverage.py --build build-coverage --threshold 90 --branch-threshold 45 ``` It needs nothing but Python 3 and gcc's own `gcov` โ€” no lcov, gcovr or genhtml. @@ -129,9 +134,23 @@ score: ```sh cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \ - -DAKSL_COVERAGE_THRESHOLD=50 -DAKSL_COVERAGE_BRANCH_THRESHOLD=20 + -DAKSL_COVERAGE_THRESHOLD=90 -DAKSL_COVERAGE_BRANCH_THRESHOLD=45 ``` +**Where it stands.** `src/stdlib.c` is at **99.0% of lines (200/202)**, **51.0% of +branches** and **21/21 functions**, so 90/45 above is a ratchet with headroom +rather than a target. The two uncovered lines are both +`} HANDLE(e, AKERR_ITERATOR_BREAK) {` โ€” in libakerror that macro begins with the +`break;` belonging to `PROCESS`'s `case 0:` arm, which is only reachable when a +callback returns a non-NULL error context whose status is *zero*. That is the +pathological case ยง2.2.1 of `TODO.md` exists to remove, so it is left uncovered +deliberately rather than pinned by a test. + +Branch coverage sits far below line coverage because most branches in this file +are inside the `FAIL_*`/`ATTEMPT`/`FINISH` macro expansions โ€” pool exhaustion, +stack-trace buffer limits, `akerr_valid_error_address` failures โ€” and belong to +libakerror's own suite rather than to this one. + Two caveats. Coverage is measured at `-O0`, because the optimizer reorders lines until per-line counts stop matching the source โ€” so a coverage build is not the build to profile. And gcov flushes its counters at normal process exit, which an @@ -155,7 +174,7 @@ or drive the script directly for a faster or narrower run: scripts/mutation_test.py --target src/stdlib.c # C source only scripts/mutation_test.py --target src/stdlib.c --list # enumerate, build nothing scripts/mutation_test.py --target src/stdlib.c --max-mutants 20 -scripts/mutation_test.py --target src/stdlib.c --threshold 40 +scripts/mutation_test.py --target src/stdlib.c --threshold 80 ``` A mutant that makes the tests fail is *killed* (good); one the tests still pass @@ -164,10 +183,23 @@ run prints every survivor with `file:line` and the exact edit. The harness never touches your working tree โ€” it copies the repo to a scratch directory and mutates the copy. -CI runs the `src/stdlib.c` set with `--threshold 40`. That is a regression -ratchet rather than a quality bar: the current score is 46.8%, and the survivors -are concentrated in the wrappers that have no tests yet. Raise the threshold as -coverage lands. +CI runs the `src/stdlib.c` set with `--threshold 80`. That is a regression ratchet +rather than a quality bar: the current score is **89.6% (155/173 killed)**, up +from 46.8% before the wrapper tests landed. Raise the threshold as the remaining +survivors are turned into assertions. + +The 18 survivors cluster in three places, and each names a real gap rather than a +test-harness artifact: + +- **Statements whose absence nothing observes** โ€” deleting `free(ptr)`, + `obj->next = NULL`, or a `SUCCEED_RETURN` leaves behaviour the suite does not + look at (a leak, a stale pointer, a success that was already NULL). +- **The `aksl_list_append` cycle/tail walk** (`tail = slow`, `slow = slow->next`, + `tail = fast`) โ€” the function is broken in exactly this area (`TODO.md` ยง2.1.1), + so its known-failing test cannot pin the internals yet. +- **`lalloc`/`lfree` defaulting in `aksl_tree_iterate`** โ€” dead parameters + (ยง2.2.8): they are defaulted and then never called, so inverting the guard + changes nothing observable. ## The pre-push hook @@ -187,5 +219,5 @@ AKSL_HOOK_MUTATION=1 git push # also run the mutation gate (slow) git push --no-verify # skip the hook entirely ``` -Other knobs: `AKSL_MUTATION_THRESHOLD` (default 40, keep it in step with +Other knobs: `AKSL_MUTATION_THRESHOLD` (default 80, keep it in step with `.gitea/workflows/ci.yaml`) and `AKSL_HOOK_BUILD_DIR`. diff --git a/TODO.md b/TODO.md index ea8e829..99d16ce 100644 --- a/TODO.md +++ b/TODO.md @@ -57,21 +57,20 @@ against `tests/aksl_capture.h` and added to the `AKSL_TESTS` list. - [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`. CI runs the narrower `src/stdlib.c` set (173 - mutants) as its own `mutation_test` job with `--threshold 40` and a JUnit report. - **Current score: 46.8%** โ€” 81 killed, 92 survived. The survivors are concentrated - exactly where ยง1.1โ€“1.9 have yet to be written: + mutants) as its own `mutation_test` job with `--threshold 80` and a JUnit report. + **Current score: 89.6%** โ€” 155 killed, 18 survived. It was 46.8% (81 killed, 92 + survived) before ยง1.2โ€“1.6 were written; the remaining survivors are: - | surviving mutants | function | - |---|---| - | 10 | `aksl_tree_iterate` | - | 6 each | `aksl_fread`, `aksl_fwrite`, `aksl_fprintf`, `aksl_sprintf` | - | 5 | `aksl_printf` | - | 4 each | `aksl_memcpy`, `aksl_realpath`, `aksl_strhash_djb2`, `aksl_list_append` | - | 3 each | `aksl_malloc`, `aksl_free`, `aksl_memset`, `aksl_fopen`, `aksl_fclose`, `aksl_atoi`, `aksl_atol`, `aksl_atoll`, `aksl_atof` | - | 1 | `aksl_list_iterate` | + | surviving mutants | where | why | + |---|---|---| + | 5 | deleted statements whose absence nothing observes (`free(ptr)`, `obj->next = NULL`, three `SUCCEED_RETURN`s) | needs an assertion on the side effect, not on the status | + | 4 | `aksl_list_append`'s cycle/tail walk | the function is broken here (ยง2.1.1); its known-failing test cannot pin the internals yet | + | 4 | `lalloc`/`lfree` defaulting in `aksl_tree_iterate` | dead parameters (ยง2.2.8) โ€” defaulted, then never called | + | 3 | `*count == -1` in the `printf` family | `*count` on the error path is itself a contract gap (ยง1.3) | + | 2 | `feof` in `aksl_fwrite`, `break` after the BFS `FAIL_RETURN` | unreachable in practice | - The threshold is a regression ratchet, not a quality bar; raise it as sections below - land. Each surviving mutant is a concrete missing test โ€” `mutation-junit.xml` lists + The threshold is a regression ratchet, not a quality bar; raise it as those survivors + become assertions. Each one is a concrete missing test โ€” `mutation-junit.xml` lists them with `file:line` and the exact edit. - [x] **Cap every test with a CTest `TIMEOUT`.** Found while measuring the above: a mutant that deletes `fast = fast->next->next` turns `aksl_list_iterate` into an infinite @@ -108,75 +107,93 @@ against `tests/aksl_capture.h` and added to the `AKSL_TESTS` list. ### 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 +Covered by `tests/test_stream.c`. + +- [x] `aksl_fopen` happy path on a temp file; `*fp` is written. +- [x] `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`. +- [x] `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). +- [x] `aksl_fread` full read; short read at EOF โ†’ `AKERR_EOF`; read from a write-only stream + โ†’ `AKERR_IO`; `fp == NULL` โ†’ `AKERR_NULLPOINTER`. `ptr == NULL` is still unchecked and + untested (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. +- [x] `aksl_fwrite` happy path; write to a read-only stream โ†’ `AKERR_IO`; `fp == NULL` โ†’ + `AKERR_NULLPOINTER`. The full-device (`/dev/full`) case is still open. +- [x] `aksl_fclose` happy path; `NULL` โ†’ `AKERR_NULLPOINTER`. Double-close is still + undecided, so it is neither caught nor tested. - [ ] `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. +- [x] 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. +Covered by `tests/test_format.c`. + +- [x] `aksl_printf` / `aksl_fprintf` / `aksl_sprintf` happy paths, asserting both the byte + count written through `count` and the produced text. (`aksl_printf` is checked by + pointing `stdout` at a temp file for the duration of the call.) +- [x] Each of the three with every pointer argument NULL in turn โ†’ `AKERR_NULLPOINTER`. +- [x] `aksl_fprintf` to a read-only stream โ†’ the `errno` it saw (`EBADF`) with "Short write" + in the message. `*count` is still left holding `-1`; the test asserts the status only, + so it will not have to change when that is fixed. - [ ] `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. +- [x] Regression test for the missing `va_end` (ยง2.1.4) โ€” 512 variadic calls in a loop, which + the sanitizer build also runs. - [ ] 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 +Happy paths and NULL guards are covered by `tests/test_convert.c`; the strict-conversion +contract is asserted by `tests/test_convert_strict.c`, which is registered as a known +failure until ยง2.1.5 is fixed. + +- [x] `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`. +- [x] NULL `nptr` and NULL `dest` for each โ†’ `AKERR_NULLPOINTER`. +- [x] **Non-numeric input** (`"not a number"`) โ€” **[CONFIRMED]** currently returns *success* + with `*dest == 0`. `AKERR_VALUE` is asserted by the known-failing test. +- [x] **Overflow** (`"99999999999999999999"`) โ€” **[CONFIRMED]** currently returns success with + a garbage value (`-1` on this box). `ERANGE` is asserted by the known-failing test. +- [x] Empty string, `" "` and `"12abc"` (trailing junk) โ†’ `AKERR_VALUE`, in the known-failing + test. `"0x10"` and `"inf"`/`"nan"` for `atof` are still open. - [ ] `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`. +Covered by `tests/test_path.c`. Every failure case there passes a zeroed `resolved_path`, +because the wrapper's own error path formats that buffer with `%s` (ยง2.1.6). + +- [x] Happy path on an existing file and on a symlink chain; result matches `realpath(3)`. +- [x] Non-existent path โ†’ `ENOENT`. +- [x] A path component that is not a directory โ†’ `ENOTDIR`. - [ ] Symlink loop โ†’ `ELOOP`. -- [ ] `path == NULL` โ†’ `AKERR_NULLPOINTER`. +- [x] `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 +Covered by `tests/test_strhash.c`. + +- [x] 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`. +- [x] `len == 0` returns 5381 regardless of `str` contents. +- [x] 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). + expected value must be the `unsigned char` one (5868578; the wrapper returns the + sign-extended 5859874 today). Every vector in the test file is 7-bit ASCII precisely so + that it says nothing about this case either way. +- [x] Embedded NUL bytes are hashed (the function is length-driven, not NUL-driven). +- [x] Same input โ†’ same output across two calls (no hidden state). ### 1.7 Linked list @@ -220,8 +237,11 @@ against `tests/aksl_capture.h` and added to the `AKSL_TESTS` list. visits 0, 1, 3, so breaking at `tree[3]` must stop the walk at **3** visits, and today it runs on to all 7. Add the equivalent for in-order (3, 1, 4, 0, 5, 2, 6 โ†’ breaking at `tree[4]` gives 3) and post-order (3, 4, 1, 5, 6, 2, 0 โ†’ breaking at `tree[5]` gives 4). -- [ ] `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). +- [x] `AKSL_TREE_SEARCH_BFS` and `AKSL_TREE_SEARCH_BFS_RIGHT` โ†’ `AKERR_NOT_IMPLEMENTED` + today, asserted in `tests/test_tree.c` along with the callback never being called; + replace with real order assertions once implemented (ยง3 / ยง2.2.9). +- [x] `aksl_tree_iterate` NULL `root` / NULL `iter` โ†’ `AKERR_NULLPOINTER`, and a callback error + that is *not* `AKERR_ITERATOR_BREAK` propagates out to the caller. - [ ] **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 @@ -257,11 +277,12 @@ against `tests/aksl_capture.h` and added to the `AKSL_TESTS` list. ### 2.1 Confirmed defects (reproduced against the built library) -The first three now have a failing test apiece, registered in `AKSL_KNOWN_FAILING_TESTS` +Four of these 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`. +`tests/test_list_append_chain.c`, `tests/test_list_iterate_head.c`, +`tests/test_tree_iterate_break.c` and `tests/test_convert_strict.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: @@ -297,6 +318,7 @@ test as failed with *unexpectedly passed* โ€” that is the cue to move it into `A `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. + `tests/test_convert_strict.c` asserts that contract and is registered as a known failure. 6. **`aksl_realpath` mishandles `resolved_path`.** `src/stdlib.c:171`. - `resolved_path` is never NULL-checked. `realpath(path, NULL)` is valid and mallocs a diff --git a/tests/aksl_capture.h b/tests/aksl_capture.h index 57dce3b..91eefec 100644 --- a/tests/aksl_capture.h +++ b/tests/aksl_capture.h @@ -28,6 +28,8 @@ * unhandled-error output. * aksl_slots_in_use() how many slots are currently checked out of * AKERR_ARRAY_ERROR, for pool-leak assertions. + * aksl_temp_file() create an empty temp file for the stream, formatted + * output and path tests to work on. * AKSL_RUN() run one test function and tally the result. * * Tests are written as a set of `static int test_xxx(void)` functions that @@ -37,7 +39,9 @@ #include #include #include +#include #include +#include /* ---------------------------------------------------------------------- */ /* Log capture */ @@ -95,6 +99,68 @@ static int __attribute__((unused)) aksl_slots_in_use(void) return n; } +/* ---------------------------------------------------------------------- */ +/* Temp files */ +/* ---------------------------------------------------------------------- */ + +#define AKSL_TMP_MAX 256 +#define AKSL_TMP_TRACKED 32 + +static char aksl_tmp_paths[AKSL_TMP_TRACKED][AKSL_TMP_MAX]; +static int aksl_tmp_count = 0; + +/* + * Unlink every path aksl_temp_file() handed out. Registered with atexit, so the + * files go away even when a test returns early on a failed assertion -- which is + * the normal case for a known-failing test and for every mutant the mutation + * harness builds. Paths a test already unlinked simply fail here, harmlessly. + */ +static void aksl_temp_cleanup(void) +{ + int i = 0; + + for ( i = 0; i < aksl_tmp_count; i++ ) { + unlink(aksl_tmp_paths[i]); + } + aksl_tmp_count = 0; +} + +/* + * Create an empty temp file under $TMPDIR (or /tmp) and write its path into buf. + * Returns 0 on success, non-zero on failure. + * + * mkstemp both names and creates the file, so a test that opens the path for + * reading is never racing another process for the name. Tests should still + * unlink what they create -- asserting on it catches a wrapper that removed or + * renamed the file -- but aksl_temp_cleanup() is the backstop. + */ +static int __attribute__((unused)) aksl_temp_file(char *buf, size_t n) +{ + const char *dir = getenv("TMPDIR"); + int fd = -1; + + if ( dir == NULL || dir[0] == '\0' ) { + dir = "/tmp"; + } + if ( (size_t)snprintf(buf, n, "%s/aksl_test_XXXXXX", dir) >= n ) { + return 1; + } + fd = mkstemp(buf); + if ( fd < 0 ) { + return 1; + } + close(fd); + + if ( aksl_tmp_count < AKSL_TMP_TRACKED ) { + if ( aksl_tmp_count == 0 && atexit(&aksl_temp_cleanup) != 0 ) { + return 0; /* tracking is best-effort; the file itself is fine */ + } + snprintf(aksl_tmp_paths[aksl_tmp_count], AKSL_TMP_MAX, "%s", buf); + aksl_tmp_count++; + } + return 0; +} + /* ---------------------------------------------------------------------- */ /* Taking ownership of a returned error context */ /* ---------------------------------------------------------------------- */ diff --git a/tests/test_convert.c b/tests/test_convert.c new file mode 100644 index 0000000..8e2636d --- /dev/null +++ b/tests/test_convert.c @@ -0,0 +1,128 @@ +/* + * String -> number wrappers: aksl_atoi / atol / atoll / atof. + * + * TODO.md section 1.4. This file covers the parts of the contract that are + * stable today: the happy paths (including negatives and leading whitespace) + * and the NULL guards. + * + * It deliberately says nothing about non-numeric input, trailing junk or + * overflow. Those all return *success* today (TODO.md 2.1.5) and the correct + * behaviour is asserted by tests/test_convert_strict.c, which is registered as + * a known failure. Pinning the current lax behaviour here as well would mean + * this file starts failing on the day the defect is fixed. + */ + +#include "aksl_capture.h" + +static int test_atoi_converts_positive(void) +{ + int out = -1; + + AKSL_CHECK_OK(aksl_atoi("1234", &out)); + AKSL_CHECK(out == 1234); + return 0; +} + +static int test_atoi_converts_negative(void) +{ + int out = 0; + + AKSL_CHECK_OK(aksl_atoi("-42", &out)); + AKSL_CHECK(out == -42); + return 0; +} + +static int test_atoi_skips_leading_whitespace(void) +{ + int out = 0; + + AKSL_CHECK_OK(aksl_atoi(" \t 7", &out)); + AKSL_CHECK(out == 7); + return 0; +} + +static int test_atoi_rejects_null_arguments(void) +{ + int out = 0; + + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_atoi(NULL, &out), + AKERR_NULLPOINTER, "nptr="); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_atoi("1", NULL), + AKERR_NULLPOINTER, "dest="); + return 0; +} + +static int test_atol_converts_and_rejects_null(void) +{ + long out = 0; + + AKSL_CHECK_OK(aksl_atol("2147483648", &out)); + AKSL_CHECK(out == 2147483648L); + AKSL_CHECK_OK(aksl_atol("-2147483648", &out)); + AKSL_CHECK(out == -2147483648L); + + AKSL_CHECK_STATUS(aksl_atol(NULL, &out), AKERR_NULLPOINTER); + AKSL_CHECK_STATUS(aksl_atol("1", NULL), AKERR_NULLPOINTER); + return 0; +} + +static int test_atoll_converts_and_rejects_null(void) +{ + long long out = 0; + + AKSL_CHECK_OK(aksl_atoll("9007199254740993", &out)); + AKSL_CHECK(out == 9007199254740993LL); + AKSL_CHECK_OK(aksl_atoll("-9007199254740993", &out)); + AKSL_CHECK(out == -9007199254740993LL); + + AKSL_CHECK_STATUS(aksl_atoll(NULL, &out), AKERR_NULLPOINTER); + AKSL_CHECK_STATUS(aksl_atoll("1", NULL), AKERR_NULLPOINTER); + return 0; +} + +static int test_atof_converts_and_rejects_null(void) +{ + double out = 0.0; + + AKSL_CHECK_OK(aksl_atof("2.5", &out)); + AKSL_CHECK(out == 2.5); + AKSL_CHECK_OK(aksl_atof(" -0.125", &out)); + AKSL_CHECK(out == -0.125); + + AKSL_CHECK_STATUS(aksl_atof(NULL, &out), AKERR_NULLPOINTER); + AKSL_CHECK_STATUS(aksl_atof("1", NULL), AKERR_NULLPOINTER); + return 0; +} + +/* The wrappers hold no state: the same input converts the same way twice. */ +static int test_conversions_are_repeatable(void) +{ + int first = 0; + int second = 0; + + AKSL_CHECK_OK(aksl_atoi("321", &first)); + AKSL_CHECK_OK(aksl_atoi("321", &second)); + AKSL_CHECK(first == second); + AKSL_CHECK(first == 321); + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + + AKSL_RUN(failures, test_atoi_converts_positive); + AKSL_RUN(failures, test_atoi_converts_negative); + AKSL_RUN(failures, test_atoi_skips_leading_whitespace); + AKSL_RUN(failures, test_atoi_rejects_null_arguments); + + AKSL_RUN(failures, test_atol_converts_and_rejects_null); + AKSL_RUN(failures, test_atoll_converts_and_rejects_null); + AKSL_RUN(failures, test_atof_converts_and_rejects_null); + + AKSL_RUN(failures, test_conversions_are_repeatable); + + AKSL_REPORT(failures); +} diff --git a/tests/test_convert_strict.c b/tests/test_convert_strict.c new file mode 100644 index 0000000..a67bbd0 --- /dev/null +++ b/tests/test_convert_strict.c @@ -0,0 +1,75 @@ +/* + * KNOWN FAILING -- TODO.md section 2.1.5 + * + * The ato* wrappers cannot report a conversion failure. atoi(3) and friends have + * no error channel at all: "not a number" converts to 0 and an overflowing + * literal converts to a wrapped value, and in both cases the wrapper hands back + * success. A library whose entire purpose is turning silent libc failures into + * error contexts should not be the one place a bad conversion passes unnoticed. + * + * This test asserts the contract the fix should provide -- reimplemented over + * strtol/strtoll/strtod with errno cleared, an endptr check for "no digits + * consumed" and "trailing junk", and a range check: + * + * no digits consumed / trailing junk -> AKERR_VALUE + * value out of range -> ERANGE + * + * Registered in AKSL_KNOWN_FAILING_TESTS (WILL_FAIL). When the wrappers get a + * real error channel, CTest reports this as unexpectedly passing -- move it into + * AKSL_TESTS then, and fold the cases into tests/test_convert.c. + */ + +#include "aksl_capture.h" + +#include + +static int test_non_numeric_input_is_a_value_error(void) +{ + int out = 0; + + AKSL_CHECK_STATUS(aksl_atoi("not a number", &out), AKERR_VALUE); + return 0; +} + +static int test_empty_input_is_a_value_error(void) +{ + int out = 0; + + AKSL_CHECK_STATUS(aksl_atoi("", &out), AKERR_VALUE); + AKSL_CHECK_STATUS(aksl_atoi(" ", &out), AKERR_VALUE); + return 0; +} + +static int test_trailing_junk_is_a_value_error(void) +{ + int out = 0; + + AKSL_CHECK_STATUS(aksl_atoi("12abc", &out), AKERR_VALUE); + return 0; +} + +static int test_overflow_is_erange(void) +{ + int out = 0; + long lout = 0; + long long llout = 0; + + AKSL_CHECK_STATUS(aksl_atoi("99999999999999999999", &out), ERANGE); + AKSL_CHECK_STATUS(aksl_atol("99999999999999999999999999", &lout), ERANGE); + AKSL_CHECK_STATUS(aksl_atoll("99999999999999999999999999", &llout), ERANGE); + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + + AKSL_RUN(failures, test_non_numeric_input_is_a_value_error); + AKSL_RUN(failures, test_empty_input_is_a_value_error); + AKSL_RUN(failures, test_trailing_junk_is_a_value_error); + AKSL_RUN(failures, test_overflow_is_erange); + + AKSL_REPORT(failures); +} diff --git a/tests/test_format.c b/tests/test_format.c new file mode 100644 index 0000000..9767aae --- /dev/null +++ b/tests/test_format.c @@ -0,0 +1,224 @@ +/* + * Formatted-output wrappers: aksl_printf / aksl_fprintf / aksl_sprintf. + * + * TODO.md section 1.3. Each happy path asserts both halves of the contract -- + * the byte count handed back through *count and the text that actually landed + * somewhere -- and every pointer argument is checked for its NULL guard. + * + * Readback goes through plain libc rather than aksl_fread so that a failure here + * points at the formatted-output wrapper under test and not at the stream + * wrappers, which tests/test_stream.c covers. + * + * Not covered: the destination-overflow case, because aksl_sprintf wraps the + * unbounded vsprintf and there is no bounded entry point to test yet + * (TODO.md 2.2.4). + */ + +#include "aksl_capture.h" + +#include + +/* Read a whole file into buf and NUL-terminate. Returns bytes read, or -1. */ +static long read_file(const char *path, char *buf, size_t n) +{ + FILE *fp = fopen(path, "r"); + size_t got = 0; + + if ( fp == NULL ) { + return -1; + } + got = fread(buf, 1, n - 1, fp); + buf[got] = '\0'; + if ( ferror(fp) ) { + fclose(fp); + return -1; + } + fclose(fp); + return (long)got; +} + +static int test_sprintf_writes_text_and_count(void) +{ + char buf[64]; + int count = -1; + + memset(buf, 0x00, sizeof(buf)); + AKSL_CHECK_OK(aksl_sprintf(&count, buf, "%s=%d", "x", 7)); + AKSL_CHECK(count == 3); + AKSL_CHECK(strcmp(buf, "x=7") == 0); + return 0; +} + +static int test_sprintf_empty_format_writes_nothing(void) +{ + char buf[8] = { 'z', 'z', 'z', 'z', 'z', 'z', 'z', 'z' }; + int count = -1; + + AKSL_CHECK_OK(aksl_sprintf(&count, buf, "%s", "")); + AKSL_CHECK(count == 0); + AKSL_CHECK(buf[0] == '\0'); + return 0; +} + +static int test_sprintf_rejects_null_arguments(void) +{ + char buf[8]; + int count = 0; + + memset(buf, 0x00, sizeof(buf)); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_sprintf(NULL, buf, "x"), + AKERR_NULLPOINTER, "count="); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_sprintf(&count, NULL, "x"), + AKERR_NULLPOINTER, "str="); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_sprintf(&count, buf, NULL), + AKERR_NULLPOINTER, "format="); + return 0; +} + +static int test_fprintf_writes_to_stream(void) +{ + char path[AKSL_TMP_MAX]; + char readback[64]; + FILE *fp = NULL; + int count = -1; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + AKSL_CHECK_OK(aksl_fopen(path, "w", &fp)); + AKSL_CHECK_OK(aksl_fprintf(&count, fp, "%s %d", "value", 42)); + AKSL_CHECK_OK(aksl_fclose(fp)); + + AKSL_CHECK(count == 8); + AKSL_CHECK(read_file(path, readback, sizeof(readback)) == 8); + AKSL_CHECK(strcmp(readback, "value 42") == 0); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +/* + * vfprintf on a stream opened "r" fails outright, so the wrapper reports the + * errno it saw (EBADF on glibc). *count is left holding -1 in this case, which + * TODO.md 1.3 flags as a contract gap -- the status is the assertion here. + */ +static int test_fprintf_to_read_only_stream_reports_errno(void) +{ + char path[AKSL_TMP_MAX]; + FILE *fp = NULL; + int count = 0; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + AKSL_CHECK_OK(aksl_fopen(path, "r", &fp)); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fprintf(&count, fp, "%d", 1), + EBADF, "Short write"); + AKSL_CHECK_OK(aksl_fclose(fp)); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +static int test_fprintf_rejects_null_arguments(void) +{ + int count = 0; + + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fprintf(NULL, stdout, "x"), + AKERR_NULLPOINTER, "count="); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fprintf(&count, NULL, "x"), + AKERR_NULLPOINTER, "stream="); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fprintf(&count, stdout, NULL), + AKERR_NULLPOINTER, "format="); + return 0; +} + +/* + * aksl_printf writes to stdout, so stdout is pointed at a temp file for the + * duration of the call and then restored through a dup of the original + * descriptor. Nothing between the freopen and the dup2 may return early: an + * assertion there would leave stdout attached to the temp file for the rest of + * the run, and the test report itself would vanish. + */ +static int test_printf_writes_to_stdout(void) +{ + char path[AKSL_TMP_MAX]; + char readback[64]; + akerr_ErrorContext *err = NULL; + int count = -1; + int saved = -1; + int restored = -1; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + + fflush(stdout); + saved = dup(fileno(stdout)); + AKSL_CHECK(saved >= 0); + if ( freopen(path, "w", stdout) == NULL ) { + close(saved); + AKSL_CHECK(0); + } + + err = aksl_printf(&count, "%s#%d", "out", 5); + + fflush(stdout); + restored = dup2(saved, fileno(stdout)); + close(saved); + clearerr(stdout); + + AKSL_CHECK(restored >= 0); + AKSL_CHECK(aksl_take(err) == 0); + AKSL_CHECK(count == 5); + AKSL_CHECK(read_file(path, readback, sizeof(readback)) == 5); + AKSL_CHECK(strcmp(readback, "out#5") == 0); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +static int test_printf_rejects_null_arguments(void) +{ + int count = 0; + + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_printf(NULL, "x"), + AKERR_NULLPOINTER, "count="); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_printf(&count, NULL), + AKERR_NULLPOINTER, "format="); + return 0; +} + +/* + * Regression cover for the missing va_end (TODO.md 2.1.4). Nothing here can + * assert on register-save state directly; the point is to run the variadic + * wrappers enough times, with enough arguments, that the sanitizer build has + * something to trip over. + */ +static int test_variadic_wrappers_survive_repeated_calls(void) +{ + char buf[128]; + int count = 0; + int i = 0; + + for ( i = 0; i < 512; i++ ) { + AKSL_CHECK_OK(aksl_sprintf(&count, buf, "%d %s %ld %c %f", + i, "iteration", (long)i, 'x', (double)i)); + AKSL_CHECK(count > 0); + AKSL_CHECK((size_t)count == strlen(buf)); + } + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + + AKSL_RUN(failures, test_sprintf_writes_text_and_count); + AKSL_RUN(failures, test_sprintf_empty_format_writes_nothing); + AKSL_RUN(failures, test_sprintf_rejects_null_arguments); + + AKSL_RUN(failures, test_fprintf_writes_to_stream); + AKSL_RUN(failures, test_fprintf_to_read_only_stream_reports_errno); + AKSL_RUN(failures, test_fprintf_rejects_null_arguments); + + AKSL_RUN(failures, test_printf_writes_to_stdout); + AKSL_RUN(failures, test_printf_rejects_null_arguments); + + AKSL_RUN(failures, test_variadic_wrappers_survive_repeated_calls); + + AKSL_REPORT(failures); +} diff --git a/tests/test_path.c b/tests/test_path.c new file mode 100644 index 0000000..a8a359a --- /dev/null +++ b/tests/test_path.c @@ -0,0 +1,116 @@ +/* + * aksl_realpath -- TODO.md section 1.5. + * + * 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). + */ + +#include "aksl_capture.h" + +#include +#include + +static int test_resolves_an_existing_file(void) +{ + char path[AKSL_TMP_MAX]; + char resolved[PATH_MAX]; + char expected[PATH_MAX]; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + memset(resolved, 0x00, sizeof(resolved)); + AKSL_CHECK(realpath(path, expected) != NULL); + + AKSL_CHECK_OK(aksl_realpath(path, resolved)); + AKSL_CHECK(strcmp(resolved, expected) == 0); + AKSL_CHECK(resolved[0] == '/'); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +/* A symlink resolves to its target, not to itself. */ +static int test_resolves_a_symlink_to_its_target(void) +{ + char target[AKSL_TMP_MAX]; + char link[AKSL_TMP_MAX]; + char resolved[PATH_MAX]; + char expected[PATH_MAX]; + + AKSL_CHECK(aksl_temp_file(target, sizeof(target)) == 0); + AKSL_CHECK(aksl_temp_file(link, sizeof(link)) == 0); + /* mkstemp created the link path as a regular file; symlink needs it gone. */ + AKSL_CHECK(unlink(link) == 0); + AKSL_CHECK(symlink(target, link) == 0); + + memset(resolved, 0x00, sizeof(resolved)); + AKSL_CHECK(realpath(target, expected) != NULL); + AKSL_CHECK_OK(aksl_realpath(link, resolved)); + AKSL_CHECK(strcmp(resolved, expected) == 0); + + AKSL_CHECK(unlink(link) == 0); + AKSL_CHECK(unlink(target) == 0); + return 0; +} + +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), + ENOENT, "/nonexistent/aksl/path"); + return 0; +} + +/* A regular file used as a directory component is ENOTDIR, not ENOENT. */ +static int test_non_directory_component_reports_enotdir(void) +{ + char path[AKSL_TMP_MAX]; + char child[AKSL_TMP_MAX + 8]; + char resolved[PATH_MAX]; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + 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(unlink(path) == 0); + return 0; +} + +static int test_rejects_null_path(void) +{ + char resolved[PATH_MAX]; + + memset(resolved, 0x00, sizeof(resolved)); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_realpath(NULL, resolved), + AKERR_NULLPOINTER, "path="); + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + + AKSL_RUN(failures, test_resolves_an_existing_file); + 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_REPORT(failures); +} diff --git a/tests/test_stream.c b/tests/test_stream.c new file mode 100644 index 0000000..b0b2a0f --- /dev/null +++ b/tests/test_stream.c @@ -0,0 +1,188 @@ +/* + * Stream wrappers: aksl_fopen / aksl_fread / aksl_fwrite / aksl_fclose. + * + * TODO.md section 1.2. Covered here: the happy paths, the round trip, the NULL + * guards that exist, and the two error statuses the wrappers can actually + * produce today -- AKERR_EOF from a short read and AKERR_IO from a stream whose + * error indicator is set. + * + * Not covered, because the behaviour is a documented gap rather than a + * contract: aksl_fopen(NULL, ...) and aksl_fopen(path, NULL, ...) are unchecked + * (2.2.2), aksl_fread/aksl_fwrite never check ptr and report a short transfer + * that is neither EOF nor error as complete success (2.2.3). + * + * Temp files come from aksl_temp_file() and are unlinked by the test that made + * them, so a failing test leaves nothing behind but the file it was mid-way + * through. + */ + +#include "aksl_capture.h" + +#include + +static int test_fopen_writes_stream_pointer(void) +{ + char path[AKSL_TMP_MAX]; + FILE *fp = NULL; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + AKSL_CHECK_OK(aksl_fopen(path, "w", &fp)); + AKSL_CHECK(fp != NULL); + AKSL_CHECK_OK(aksl_fclose(fp)); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +static int test_fopen_reports_missing_path(void) +{ + FILE *fp = NULL; + + /* The pathname belongs in the message; the status is the errno fopen saw. */ + AKSL_CHECK_STATUS_MSG_CONTAINS( + aksl_fopen("/nonexistent/aksl/stream", "r", &fp), + ENOENT, "/nonexistent/aksl/stream"); + return 0; +} + +static int test_fopen_rejects_null_stream_out(void) +{ + char path[AKSL_TMP_MAX]; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fopen(path, "r", NULL), + AKERR_NULLPOINTER, "NULL"); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +/* fopen -> fwrite -> fclose -> fopen -> fread -> compare, all through the wrappers. */ +static int test_write_read_round_trip(void) +{ + char path[AKSL_TMP_MAX]; + char payload[] = "libakstdlib round trip"; + char readback[sizeof(payload)]; + FILE *fp = NULL; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + + AKSL_CHECK_OK(aksl_fopen(path, "w", &fp)); + AKSL_CHECK_OK(aksl_fwrite(payload, 1, sizeof(payload), fp)); + AKSL_CHECK_OK(aksl_fclose(fp)); + + fp = NULL; + memset(readback, 0x00, sizeof(readback)); + AKSL_CHECK_OK(aksl_fopen(path, "r", &fp)); + AKSL_CHECK_OK(aksl_fread(readback, 1, sizeof(readback), fp)); + AKSL_CHECK_OK(aksl_fclose(fp)); + + AKSL_CHECK(memcmp(payload, readback, sizeof(payload)) == 0); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +/* Asking for more members than the file holds sets feof, which is AKERR_EOF. */ +static int test_fread_short_read_is_eof(void) +{ + char path[AKSL_TMP_MAX]; + char buf[32]; + FILE *fp = NULL; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + + AKSL_CHECK_OK(aksl_fopen(path, "w", &fp)); + AKSL_CHECK_OK(aksl_fwrite("abcd", 1, 4, fp)); + AKSL_CHECK_OK(aksl_fclose(fp)); + + fp = NULL; + memset(buf, 0x00, sizeof(buf)); + AKSL_CHECK_OK(aksl_fopen(path, "r", &fp)); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fread(buf, 1, sizeof(buf), fp), + AKERR_EOF, "EOF"); + AKSL_CHECK_OK(aksl_fclose(fp)); + + /* The bytes that did arrive are still in the buffer. */ + AKSL_CHECK(memcmp(buf, "abcd", 4) == 0); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +/* + * A stream opened "w" has no read permission, so fread sets the error indicator + * rather than the EOF one: AKERR_IO, not AKERR_EOF. + */ +static int test_fread_from_write_only_stream_is_io_error(void) +{ + char path[AKSL_TMP_MAX]; + char buf[4]; + FILE *fp = NULL; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + AKSL_CHECK_OK(aksl_fopen(path, "w", &fp)); + memset(buf, 0x00, sizeof(buf)); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fread(buf, 1, sizeof(buf), fp), + AKERR_IO, "Error reading file"); + AKSL_CHECK_OK(aksl_fclose(fp)); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +static int test_fread_rejects_null_stream(void) +{ + char buf[4]; + + memset(buf, 0x00, sizeof(buf)); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fread(buf, 1, sizeof(buf), NULL), + AKERR_NULLPOINTER, "NULL"); + return 0; +} + +/* Mirror image of the fread case: a "r" stream cannot be written to. */ +static int test_fwrite_to_read_only_stream_is_io_error(void) +{ + char path[AKSL_TMP_MAX]; + FILE *fp = NULL; + + AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0); + AKSL_CHECK_OK(aksl_fopen(path, "r", &fp)); + AKSL_CHECK_STATUS(aksl_fwrite("xy", 1, 2, fp), AKERR_IO); + AKSL_CHECK_OK(aksl_fclose(fp)); + AKSL_CHECK(unlink(path) == 0); + return 0; +} + +static int test_fwrite_rejects_null_stream(void) +{ + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fwrite("xy", 1, 2, NULL), + AKERR_NULLPOINTER, "NULL"); + return 0; +} + +static int test_fclose_rejects_null_stream(void) +{ + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_fclose(NULL), + AKERR_NULLPOINTER, "NULL"); + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + + AKSL_RUN(failures, test_fopen_writes_stream_pointer); + AKSL_RUN(failures, test_fopen_reports_missing_path); + AKSL_RUN(failures, test_fopen_rejects_null_stream_out); + + AKSL_RUN(failures, test_write_read_round_trip); + AKSL_RUN(failures, test_fread_short_read_is_eof); + AKSL_RUN(failures, test_fread_from_write_only_stream_is_io_error); + AKSL_RUN(failures, test_fread_rejects_null_stream); + + AKSL_RUN(failures, test_fwrite_to_read_only_stream_is_io_error); + AKSL_RUN(failures, test_fwrite_rejects_null_stream); + + AKSL_RUN(failures, test_fclose_rejects_null_stream); + + AKSL_REPORT(failures); +} diff --git a/tests/test_strhash.c b/tests/test_strhash.c new file mode 100644 index 0000000..57fd3f2 --- /dev/null +++ b/tests/test_strhash.c @@ -0,0 +1,103 @@ +/* + * aksl_strhash_djb2 -- TODO.md section 1.6. + * + * The expected values are the canonical djb2 ones: h = 5381, then + * 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. + */ + +#include "aksl_capture.h" + +static int test_empty_string_is_the_djb2_seed(void) +{ + uint32_t h = 0; + + AKSL_CHECK_OK(aksl_strhash_djb2("", 0, &h)); + AKSL_CHECK(h == 5381); + return 0; +} + +/* len drives the loop, so a zero length ignores the contents entirely. */ +static int test_zero_length_ignores_the_buffer(void) +{ + char buf[] = "ignored"; + uint32_t h = 0; + + AKSL_CHECK_OK(aksl_strhash_djb2(buf, 0, &h)); + AKSL_CHECK(h == 5381); + return 0; +} + +static int test_known_answer_vectors(void) +{ + char hello[] = "hello"; + char libname[] = "libakstdlib"; + uint32_t h = 0; + + AKSL_CHECK_OK(aksl_strhash_djb2(hello, 5, &h)); + AKSL_CHECK(h == 261238937u); + + AKSL_CHECK_OK(aksl_strhash_djb2(libname, 11, &h)); + AKSL_CHECK(h == 884285482u); + return 0; +} + +/* The function is length-driven, not NUL-driven: an embedded NUL is hashed. */ +static int test_embedded_nul_is_hashed(void) +{ + char buf[3] = { 'a', '\0', 'b' }; + uint32_t whole = 0; + uint32_t prefix = 0; + + AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf), &whole)); + AKSL_CHECK(whole == 193482728u); + + /* Stopping at the NUL would give the one-byte hash instead. */ + AKSL_CHECK_OK(aksl_strhash_djb2(buf, 1, &prefix)); + AKSL_CHECK(prefix != whole); + return 0; +} + +static int test_hash_is_stable_across_calls(void) +{ + char buf[] = "repeatable"; + uint32_t first = 0; + uint32_t second = 0; + + AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf) - 1, &first)); + AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf) - 1, &second)); + AKSL_CHECK(first == second); + return 0; +} + +static int test_rejects_null_arguments(void) +{ + char buf[] = "x"; + uint32_t h = 0; + + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_strhash_djb2(NULL, 1, &h), + AKERR_NULLPOINTER, "str"); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_strhash_djb2(buf, 1, NULL), + AKERR_NULLPOINTER, "hashval"); + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + + 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_embedded_nul_is_hashed); + AKSL_RUN(failures, test_hash_is_stable_across_calls); + AKSL_RUN(failures, test_rejects_null_arguments); + + AKSL_REPORT(failures); +} diff --git a/tests/test_tree.c b/tests/test_tree.c index 6147fe4..201703f 100644 --- a/tests/test_tree.c +++ b/tests/test_tree.c @@ -99,6 +99,70 @@ static int test_dfs_postorder(void) return search_finds_hidden_value(AKSL_TREE_SEARCH_DFS_POSTORDER); } +/* + * Both breadth-first modes are declared in the header but not implemented, and + * say so through AKERR_NOT_IMPLEMENTED rather than by silently visiting nothing. + * TODO.md 2.2.10 tracks implementing them; until then this is the contract. + */ +static int bfs_reports_not_implemented(uint8_t searchmode) +{ + aksl_TreeNode tree[MAX_LEAVES]; + TreeSearchParams parms; + + build_tree(tree, &parms); + + AKSL_CHECK_STATUS_MSG_CONTAINS( + aksl_tree_iterate(&tree[0], &find_value, NULL, NULL, searchmode, + &parms, NULL), + AKERR_NOT_IMPLEMENTED, "Searchmode"); + AKSL_CHECK(parms.steps == 0); + AKSL_CHECK(parms.node == NULL); + return 0; +} + +static int test_bfs_is_not_implemented(void) +{ + return bfs_reports_not_implemented(AKSL_TREE_SEARCH_BFS); +} + +static int test_bfs_right_is_not_implemented(void) +{ + return bfs_reports_not_implemented(AKSL_TREE_SEARCH_BFS_RIGHT); +} + +static int test_iterate_null_arguments(void) +{ + aksl_TreeNode tree[MAX_LEAVES]; + TreeSearchParams parms; + + build_tree(tree, &parms); + + AKSL_CHECK_STATUS_MSG_CONTAINS( + aksl_tree_iterate(NULL, &find_value, NULL, NULL, + AKSL_TREE_SEARCH_DFS_PREORDER, &parms, NULL), + AKERR_NULLPOINTER, "root"); + AKSL_CHECK_STATUS_MSG_CONTAINS( + aksl_tree_iterate(&tree[0], NULL, NULL, NULL, + AKSL_TREE_SEARCH_DFS_PREORDER, &parms, NULL), + AKERR_NULLPOINTER, "iter"); + return 0; +} + +/* A callback error that is not AKERR_ITERATOR_BREAK reaches the caller. */ +static int test_iterate_propagates_callback_error(void) +{ + aksl_TreeNode tree[MAX_LEAVES]; + TreeSearchParams parms; + + build_tree(tree, &parms); + /* find_value raises AKERR_NULLPOINTER when it is handed no data. */ + AKSL_CHECK_STATUS_MSG_CONTAINS( + aksl_tree_iterate(&tree[0], &find_value, NULL, NULL, + AKSL_TREE_SEARCH_DFS_PREORDER, NULL, NULL), + AKERR_NULLPOINTER, "data"); + return 0; +} + int main(void) { int failures = 0; @@ -109,5 +173,10 @@ int main(void) AKSL_RUN(failures, test_dfs_inorder); AKSL_RUN(failures, test_dfs_postorder); + AKSL_RUN(failures, test_bfs_is_not_implemented); + AKSL_RUN(failures, test_bfs_right_is_not_implemented); + AKSL_RUN(failures, test_iterate_null_arguments); + AKSL_RUN(failures, test_iterate_propagates_callback_error); + AKSL_REPORT(failures); }