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) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:07:08 -04:00
parent 82c47ed773
commit 437da2960b
14 changed files with 1138 additions and 73 deletions

View File

@@ -31,6 +31,35 @@ jobs:
ctest --test-dir build --output-on-failure ctest --test-dir build --output-on-failure
- run: echo "🍏 This job's status is ${{ job.status }}." - 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: mutation_test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -48,19 +77,18 @@ jobs:
# confirm the suite fails. Gated on src/stdlib.c (fast, deterministic); # confirm the suite fails. Gated on src/stdlib.c (fast, deterministic);
# run the full default target locally for the macro header as well. # 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 # The threshold is a ratchet, not a quality bar. The score is 89.6%
# 1.0 suite is 46.8% (81/173 killed): the list and tree functions are # (155/173 killed) now that TODO.md sections 1.2-1.6 have tests; it was
# covered, and everything else in src/stdlib.c -- the printf family, the # 46.8% when only the list and tree functions were covered. 80 leaves
# ato* family, realpath, the memory and stream wrappers -- has no tests # headroom for the runner while still failing on a real regression (tests
# yet, so its mutants survive. 40 leaves headroom for the runner while # deleted, or new untested code added). The 18 survivors are listed in the
# still failing on a real regression (tests deleted, or new untested code # published report -- each one is a missing assertion.
# added). Raise it as TODO.md sections 1.1-1.9 land.
- name: mutation testing - name: mutation testing
run: | run: |
python3 scripts/mutation_test.py \ python3 scripts/mutation_test.py \
--target src/stdlib.c \ --target src/stdlib.c \
--junit mutation-junit.xml \ --junit mutation-junit.xml \
--threshold 40 --threshold 80
# Publish even when the threshold gate fails, so survivors are visible -- # Publish even when the threshold gate fails, so survivors are visible --
# each one is a missing test. Display-only (fail_on_failure: false); the # 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 # --threshold above is the gate. annotate_only avoids the Checks API 404

View File

@@ -29,7 +29,7 @@ set -u
# Keep in sync with the --threshold in .gitea/workflows/ci.yaml, so a push that # Keep in sync with the --threshold in .gitea/workflows/ci.yaml, so a push that
# would fail CI fails here first. # would fail CI fails here first.
MUTATION_THRESHOLD="${AKSL_MUTATION_THRESHOLD:-40}" MUTATION_THRESHOLD="${AKSL_MUTATION_THRESHOLD:-80}"
ZERO_SHA=0000000000000000000000000000000000000000 ZERO_SHA=0000000000000000000000000000000000000000

View File

@@ -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 `AKSL_KNOWN_FAILING_TESTS` assert documented defects from `TODO.md`; when one
starts unexpectedly passing, move it into `AKSL_TESTS` with the fix. 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 ## Commit & Pull Request Guidelines
Recent commits use short imperative summaries, for example `Add memory wrapper Recent commits use short imperative summaries, for example `Add memory wrapper

View File

@@ -175,8 +175,13 @@ install(FILES
# "unexpectedly passed" -- that is the cue to move # "unexpectedly passed" -- that is the cue to move
# it up into AKSL_TESTS. # it up into AKSL_TESTS.
set(AKSL_TESTS set(AKSL_TESTS
convert
format
linkedlist linkedlist
memory memory
path
stream
strhash
tree tree
) )
@@ -184,6 +189,7 @@ set(AKSL_WILL_FAIL_TESTS
) )
set(AKSL_KNOWN_FAILING_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_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 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 tree_iterate_break # TODO.md 2.1.3 -- ITERATOR_BREAK does not stop a walk

View File

@@ -41,8 +41,13 @@ ctest --test-dir build --output-on-failure
Tests live one per file in `tests/test_<name>.c` and share the helpers in Tests live one per file in `tests/test_<name>.c` and share the helpers in
`tests/aksl_capture.h``AKSL_CHECK()` for plain assertions (unlike `assert()` `tests/aksl_capture.h``AKSL_CHECK()` for plain assertions (unlike `assert()`
it survives `-DNDEBUG`), `AKSL_CHECK_STATUS(call, expected)` to run a wrapper and 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 assert on the status it returns, `aksl_temp_file()` for tests that need a real
fails any test which leaks a slot from libakerror's error pool. 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 To add a test, drop `tests/test_mything.c` in place and add `mything` to
`AKSL_TESTS` in `CMakeLists.txt`. `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 --summary-only # totals only
scripts/coverage.py --build build-coverage --include tests # coverage of the tests themselves 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 --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. It needs nothing but Python 3 and gcc's own `gcov` — no lcov, gcovr or genhtml.
@@ -129,9 +134,23 @@ score:
```sh ```sh
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \ 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 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 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 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 # C source only
scripts/mutation_test.py --target src/stdlib.c --list # enumerate, build nothing 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 --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 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 touches your working tree — it copies the repo to a scratch directory and mutates
the copy. the copy.
CI runs the `src/stdlib.c` set with `--threshold 40`. That is a regression CI runs the `src/stdlib.c` set with `--threshold 80`. That is a regression ratchet
ratchet rather than a quality bar: the current score is 46.8%, and the survivors rather than a quality bar: the current score is **89.6% (155/173 killed)**, up
are concentrated in the wrappers that have no tests yet. Raise the threshold as from 46.8% before the wrapper tests landed. Raise the threshold as the remaining
coverage lands. 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 ## 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 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`. `.gitea/workflows/ci.yaml`) and `AKSL_HOOK_BUILD_DIR`.

130
TODO.md
View File

@@ -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 - [x] **Port `scripts/mutation_test.py` from libakerror.** Retargeted at `src/stdlib.c` and
`include/akstdlib.h` (178 mutants) and exposed as `include/akstdlib.h` (178 mutants) and exposed as
`cmake --build build --target mutation`. CI runs the narrower `src/stdlib.c` set (173 `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. mutants) as its own `mutation_test` job with `--threshold 80` and a JUnit report.
**Current score: 46.8%**81 killed, 92 survived. The survivors are concentrated **Current score: 89.6%** — 155 killed, 18 survived. It was 46.8% (81 killed, 92
exactly where §1.11.9 have yet to be written: survived) before §1.21.6 were written; the remaining survivors are:
| surviving mutants | function | | surviving mutants | where | why |
|---|---| |---|---|---|
| 10 | `aksl_tree_iterate` | | 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 |
| 6 each | `aksl_fread`, `aksl_fwrite`, `aksl_fprintf`, `aksl_sprintf` | | 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 |
| 5 | `aksl_printf` | | 4 | `lalloc`/`lfree` defaulting in `aksl_tree_iterate` | dead parameters (§2.2.8) — defaulted, then never called |
| 4 each | `aksl_memcpy`, `aksl_realpath`, `aksl_strhash_djb2`, `aksl_list_append` | | 3 | `*count == -1` in the `printf` family | `*count` on the error path is itself a contract gap (§1.3) |
| 3 each | `aksl_malloc`, `aksl_free`, `aksl_memset`, `aksl_fopen`, `aksl_fclose`, `aksl_atoi`, `aksl_atol`, `aksl_atoll`, `aksl_atof` | | 2 | `feof` in `aksl_fwrite`, `break` after the BFS `FAIL_RETURN` | unreachable in practice |
| 1 | `aksl_list_iterate` |
The threshold is a regression ratchet, not a quality bar; raise it as sections below The threshold is a regression ratchet, not a quality bar; raise it as those survivors
land. Each surviving mutant is a concrete missing test — `mutation-junit.xml` lists become assertions. Each one is a concrete missing test — `mutation-junit.xml` lists
them with `file:line` and the exact edit. them with `file:line` and the exact edit.
- [x] **Cap every test with a CTest `TIMEOUT`.** Found while measuring the above: a mutant - [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 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 ### 1.2 File I/O
- [ ] `aksl_fopen` happy path on a temp file; `*fp` is written. Covered by `tests/test_stream.c`.
- [ ] `aksl_fopen("/nonexistent/path", "r", &fp)``ENOENT` propagated as the status, with
- [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. the pathname in the message.
- [ ] `aksl_fopen` on a mode-denied path (e.g. `/proc/1/mem`, or a `chmod 000` temp file) → - [ ] `aksl_fopen` on a mode-denied path (e.g. `/proc/1/mem`, or a `chmod 000` temp file) →
`EACCES`. `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, - [ ] `aksl_fopen(NULL, "r", &fp)` and `aksl_fopen(path, NULL, &fp)` → currently unchecked,
see §2.2.2. Test once the guards exist. 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 - [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` → should be `AKERR_IO`; `fp == NULL``AKERR_NULLPOINTER`. `ptr == NULL` is still unchecked and
`AKERR_NULLPOINTER` (see §2.2.3). untested (see §2.2.3).
- [ ] `aksl_fread` **partial read that is neither EOF nor error** — assert whatever the fixed - [ ] `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 contract is; today this silently returns success and the caller cannot tell how many
members were read. members were read.
- [ ] `aksl_fwrite` happy path; write to a read-only stream → `AKERR_IO`; write to a full - [x] `aksl_fwrite` happy path; write to a read-only stream → `AKERR_IO`; `fp == NULL`
device (`/dev/full`) → `ENOSPC`/`AKERR_IO`; `fp == NULL``AKERR_NULLPOINTER`. `AKERR_NULLPOINTER`. The full-device (`/dev/full`) case is still open.
- [ ] `aksl_fclose` happy path; `NULL``AKERR_NULLPOINTER`; double-close is caught or - [x] `aksl_fclose` happy path; `NULL``AKERR_NULLPOINTER`. Double-close is still
documented as UB. undecided, so it is neither caught nor tested.
- [ ] `aksl_fclose` on a stream whose buffered flush fails (`/dev/full`) → non-zero `fclose` - [ ] `aksl_fclose` on a stream whose buffered flush fails (`/dev/full`) → non-zero `fclose`
surfaces `errno`. 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 ### 1.3 Formatted output
- [ ] `aksl_printf` / `aksl_fprintf` / `aksl_sprintf` happy paths, asserting both the byte Covered by `tests/test_format.c`.
count written through `count` and the produced text.
- [ ] Each of the three with every pointer argument NULL in turn → `AKERR_NULLPOINTER`. - [x] `aksl_printf` / `aksl_fprintf` / `aksl_sprintf` happy paths, asserting both the byte
- [ ] `aksl_fprintf` to a closed / read-only stream → error path, and confirm `*count` is not count written through `count` and the produced text. (`aksl_printf` is checked by
left holding `-1` as if it were a valid length. 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 - [ ] `aksl_sprintf` with a format that overflows the destination — currently unbounded
(§2.2.4). Test the `aksl_snprintf` replacement once it exists. (§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 - [x] Regression test for the missing `va_end` (§2.1.4) — 512 variadic calls in a loop, which
wrapper many times in a loop, run under valgrind/ASan. the sanitizer build also runs.
- [ ] Format-string/arg mismatch is caught at compile time once - [ ] Format-string/arg mismatch is caught at compile time once
`__attribute__((format(printf, ...)))` is added (§2.2.5) — a negative compile test. `__attribute__((format(printf, ...)))` is added (§2.2.5) — a negative compile test.
### 1.4 String → number ### 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. leading whitespace.
- [ ] NULL `nptr` and NULL `dest` for each → `AKERR_NULLPOINTER`. - [x] NULL `nptr` and NULL `dest` for each → `AKERR_NULLPOINTER`.
- [ ] **Non-numeric input** (`"not a number"`) — **[CONFIRMED]** currently returns *success* - [x] **Non-numeric input** (`"not a number"`) — **[CONFIRMED]** currently returns *success*
with `*dest == 0`. Test the `AKERR_VALUE` behaviour once §2.1.5 is fixed. with `*dest == 0`. `AKERR_VALUE` is asserted by the known-failing test.
- [ ] **Overflow** (`"99999999999999999999"`) — **[CONFIRMED]** currently returns success with - [x] **Overflow** (`"99999999999999999999"`) — **[CONFIRMED]** currently returns success with
a garbage value (`-1` on this box). Test for `ERANGE`. a garbage value (`-1` on this box). `ERANGE` is asserted by the known-failing test.
- [ ] Empty string, `" "`, `"12abc"` (trailing junk), `"0x10"`, `"inf"`/`"nan"` for `atof`. - [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. - [ ] `LONG_MIN`/`LONG_MAX`/`LLONG_MIN`/`LLONG_MAX` boundary strings round-trip exactly.
### 1.5 `aksl_realpath` ### 1.5 `aksl_realpath`
- [ ] Happy path on an existing file and on a symlink chain; result matches `realpath(3)`. Covered by `tests/test_path.c`. Every failure case there passes a zeroed `resolved_path`,
- [ ] Non-existent path → `ENOENT`. because the wrapper's own error path formats that buffer with `%s` (§2.1.6).
- [ ] A path component that is not a directory → `ENOTDIR`.
- [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`. - [ ] 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. - [ ] `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 - [ ] A failure case where `resolved_path` is an *uninitialised* buffer — this is the crash
case in §2.1.6; run it under ASan/MSan. case in §2.1.6; run it under ASan/MSan.
### 1.6 `aksl_strhash_djb2` ### 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. pre-computed 32-bit values.
- [ ] `len == 0` returns 5381 regardless of `str` contents. - [x] `len == 0` returns 5381 regardless of `str` contents.
- [ ] NULL `str` and NULL `hashval``AKERR_NULLPOINTER`. - [x] NULL `str` and NULL `hashval``AKERR_NULLPOINTER`.
- [ ] **High-bit bytes** (`"\xff\xfe"`) — pins down the sign-extension bug in §2.2.6; the - [ ] **High-bit bytes** (`"\xff\xfe"`) — pins down the sign-extension bug in §2.2.6; the
expected value must be the `unsigned char` one. expected value must be the `unsigned char` one (5868578; the wrapper returns the
- [ ] Embedded NUL bytes are hashed (the function is length-driven, not NUL-driven). sign-extended 5859874 today). Every vector in the test file is 7-bit ASCII precisely so
- [ ] Same input → same output across two calls (no hidden state). 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 ### 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 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 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). `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` - [x] `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). 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* - [ ] **Unknown `searchmode`** (e.g. `99`) — **[CONFIRMED]** currently returns *success*
having visited nothing. Should be `AKERR_VALUE`. having visited nothing. Should be `AKERR_VALUE`.
- [ ] **`AKSL_TREE_SEARCH_VISIT`** (defined in the header, `5`) — **[CONFIRMED]** falls into - [ ] **`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) ### 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: 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_list_append_chain.c`, `tests/test_list_iterate_head.c`,
`tests/test_tree_iterate_break.c`. When one of these defects is fixed, CTest reports that `tests/test_tree_iterate_break.c` and `tests/test_convert_strict.c`. When one of these
test as failed with *unexpectedly passed* — that is the cue to move it into `AKSL_TESTS`. 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.** 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: `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 — `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 raising `AKERR_VALUE` and `ERANGE` respectively. Keep the `atoi`-compatible names but
document the stricter contract, or add `aksl_strtol`-family wrappers alongside. 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`. 6. **`aksl_realpath` mishandles `resolved_path`.** `src/stdlib.c:171`.
- `resolved_path` is never NULL-checked. `realpath(path, NULL)` is valid and mallocs a - `resolved_path` is never NULL-checked. `realpath(path, NULL)` is valid and mallocs a

View File

@@ -28,6 +28,8 @@
* unhandled-error output. * unhandled-error output.
* aksl_slots_in_use() how many slots are currently checked out of * aksl_slots_in_use() how many slots are currently checked out of
* AKERR_ARRAY_ERROR, for pool-leak assertions. * 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. * AKSL_RUN() run one test function and tally the result.
* *
* Tests are written as a set of `static int test_xxx(void)` functions that * Tests are written as a set of `static int test_xxx(void)` functions that
@@ -37,7 +39,9 @@
#include <akstdlib.h> #include <akstdlib.h>
#include <stdarg.h> #include <stdarg.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include <unistd.h>
/* ---------------------------------------------------------------------- */ /* ---------------------------------------------------------------------- */
/* Log capture */ /* Log capture */
@@ -95,6 +99,68 @@ static int __attribute__((unused)) aksl_slots_in_use(void)
return n; 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 */ /* Taking ownership of a returned error context */
/* ---------------------------------------------------------------------- */ /* ---------------------------------------------------------------------- */

128
tests/test_convert.c Normal file
View File

@@ -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);
}

View File

@@ -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 <errno.h>
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);
}

224
tests/test_format.c Normal file
View File

@@ -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 <errno.h>
/* 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);
}

116
tests/test_path.c Normal file
View File

@@ -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 <errno.h>
#include <limits.h>
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);
}

188
tests/test_stream.c Normal file
View File

@@ -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 <errno.h>
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);
}

103
tests/test_strhash.c Normal file
View File

@@ -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);
}

View File

@@ -99,6 +99,70 @@ static int test_dfs_postorder(void)
return search_finds_hidden_value(AKSL_TREE_SEARCH_DFS_POSTORDER); 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 main(void)
{ {
int failures = 0; int failures = 0;
@@ -109,5 +173,10 @@ int main(void)
AKSL_RUN(failures, test_dfs_inorder); AKSL_RUN(failures, test_dfs_inorder);
AKSL_RUN(failures, test_dfs_postorder); 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); AKSL_REPORT(failures);
} }