diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b819e95..664717b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -45,12 +45,17 @@ jobs: # 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 44.3% of branches, so 90/40 fails on a real regression (a test + # The gate is a ratchet, not a target: src/stdlib.c is at 99.1% of lines + # and 45.1% of branches, so 90/40 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. # + # Branch coverage is back over 45 (the aksl_version_* functions are fully + # covered, which lifted it from 44.3%), but the gate stays at 40: 0.1 + # points of headroom is not a ratchet, it is a coin flip on the next + # rounding change. + # # The branch gate was 45 against 51.0% until the libakerror 1.0.0 bump. # Nothing about this library's tests changed: line coverage held at # 99.0% (200/202) and function coverage at 100% (21/21), but the branch diff --git a/AGENTS.md b/AGENTS.md index 3f8aff5..bf8bee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ 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) / 40 +`src/stdlib.c` is at 99.1% line coverage and CI gates it at 90 (line) / 40 (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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6bda2fd..e9d4b1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,32 @@ cmake_minimum_required(VERSION 3.10) -project(akstdlib LANGUAGES C) +# The single source of truth for the version. It flows from here into +# include/akstdlib_version.h (generated from the .in template next to it), the +# shared library's VERSION/SOVERSION, the Version: field in akstdlib.pc, and +# akstdlibConfigVersion.cmake. Nothing else should spell a version number. +# +# 0.x on purpose: TODO.md section 2.1 still records four confirmed defects whose +# fixes change documented behaviour (aksl_atoi's contract, aksl_realpath's +# signature), so this library is not promising a stable API yet. +project(akstdlib VERSION 0.1.0 LANGUAGES C) + +# The granularity at which the ABI is allowed to break, and therefore the +# soname: libakstdlib.so.0.1. Pre-1.0 that is MAJOR.MINOR, because a 0.x library +# makes no compatibility promise across a minor bump. At 1.0 this becomes +# ${PROJECT_VERSION_MAJOR} alone -- change it here, and AKSL_VERSION_SONAME in +# the header template follows automatically because it is configured from this. +if(PROJECT_VERSION_MAJOR EQUAL 0) + set(AKSL_SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}") +else() + set(AKSL_SOVERSION "${PROJECT_VERSION_MAJOR}") +endif() + +set(AKSL_GENERATED_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/include") +set(AKSL_VERSION_HEADER "${AKSL_GENERATED_INCLUDE_DIR}/akstdlib_version.h") +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/include/akstdlib_version.h.in" + "${AKSL_VERSION_HEADER}" + @ONLY +) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -ggdb -pg") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -g -ggdb -pg") @@ -135,12 +162,25 @@ add_library(akstdlib SHARED add_library(akstdlib::akstdlib ALIAS akstdlib) -# Specify include directories for the library's headers (if applicable) +# Specify include directories for the library's headers (if applicable). +# The generated directory carries akstdlib_version.h, which akstdlib.h includes; +# it is PUBLIC because consumers building against the build tree need it too. On +# install both headers land side by side in ${CMAKE_INSTALL_INCLUDEDIR}, so the +# install interface needs no second entry. target_include_directories(akstdlib PUBLIC $ + $ $ ) +# VERSION gives the real file (libakstdlib.so.0.1.0); SOVERSION gives the symlink +# and the ELF soname recorded in every consumer (libakstdlib.so.0.1), so a +# consumer built against 0.1 will not silently load an ABI-incompatible 0.2. +set_target_properties(akstdlib PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION ${AKSL_SOVERSION} +) + target_link_libraries(akstdlib PUBLIC akerror::akerror) aksl_target_coverage(akstdlib) @@ -155,6 +195,7 @@ install(TARGETS akstdlib ) install(FILES "include/akstdlib.h" DESTINATION "include/") +install(FILES "${AKSL_VERSION_HEADER}" DESTINATION "include/") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akstdlib.pc DESTINATION "lib/pkgconfig/") @@ -170,8 +211,31 @@ configure_package_config_file( INSTALL_DESTINATION ${akstdlib_install_cmakedir} ) +# Without this, find_package(akstdlib 0.1 REQUIRED) is refused for want of a +# version file no matter what is installed -- which is exactly the gap that +# stops cmake/akstdlib.cmake.in from requesting a version of akerror. +# +# SameMinorVersion mirrors the soname: pre-1.0, 0.1 and 0.2 are different ABIs. +# It arrived in CMake 3.11 and this project declares 3.10, so fall back to +# ExactVersion on older CMake. That is stricter than the soname rule -- it pins +# the patch level too, so a 0.1.0 request refuses a compatible 0.1.1 -- but it is +# never laxer, and wrongly refusing a good pairing beats wrongly accepting a bad +# one. Drop the branch when the minimum moves past 3.11. +if(CMAKE_VERSION VERSION_LESS 3.11) + set(AKSL_VERSION_COMPATIBILITY ExactVersion) +else() + set(AKSL_VERSION_COMPATIBILITY SameMinorVersion) +endif() + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY ${AKSL_VERSION_COMPATIBILITY} +) + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/akstdlibConfigVersion.cmake" DESTINATION ${akstdlib_install_cmakedir} ) @@ -200,6 +264,7 @@ set(AKSL_TESTS stream strhash tree + version ) set(AKSL_WILL_FAIL_TESTS diff --git a/README.md b/README.md index 25588cf..d0c74ad 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,70 @@ A top-level build compiles the vendored `deps/libakerror`. When `libakstdlib` is consumed as a subproject, it uses whatever `akerror::akerror` target or installed package the parent provides instead. +### This library's own version + +`libakstdlib` is at **0.1.0**. The version lives in exactly one place — the +`project()` call in `CMakeLists.txt` — and flows from there into everything +else, so a bump is a one-line edit: + +| Artifact | Value at 0.1.0 | From | +| --- | --- | --- | +| `AKSL_VERSION_MAJOR` / `_MINOR` / `_PATCH` | `0` / `1` / `0` | `include/akstdlib_version.h.in` | +| `AKSL_VERSION_STRING` | `"0.1.0"` | same | +| `AKSL_VERSION_NUMBER` | `100` | same | +| `AKSL_VERSION_SONAME` | `"0.1"` | same | +| shared library | `libakstdlib.so.0.1.0`, soname `libakstdlib.so.0.1` | `VERSION` / `SOVERSION` | +| `pkg-config --modversion akstdlib` | `0.1.0` | `akstdlib.pc` | +| `find_package(akstdlib 0.1)` | accepted; `0.2` and `1.0` refused | `akstdlibConfigVersion.cmake` | + +`akstdlib_version.h` is **generated** — that is why there is no such file in the +source tree, only the `.in` template beside `akstdlib.h`. Don't hand-edit the +copy in your build directory; change the template or `project()`. + +It is `0.x` deliberately. `TODO.md` §2.1 still records four confirmed defects +whose fixes change documented behaviour, so the API is not being promised yet. +While the major version is `0`, **the soname carries `MAJOR.MINOR`**: 0.1 and 0.2 +are different ABIs and the loader will not substitute one for the other. At 1.0 +the soname becomes `MAJOR` alone — the `if(PROJECT_VERSION_MAJOR EQUAL 0)` in +`CMakeLists.txt` and the matching `#if` in `tests/test_version.c` are the two +places that encode this, and they are tested against each other. + +`AKSL_VERSION_NUMBER` is computed rather than written as a literal, because a +literal `000100` is *octal* in C and would make 0.1.0 compare as 64. + +#### Compiled-against vs. loaded + +The macros above record what a caller was **compiled** against. What it actually +**loaded** is a different question, and the two can disagree: + +```c +int major, minor, patch; +akerr_ErrorContext *e = aksl_version(&major, &minor, &patch); /* the loaded .so */ +const char *v = aksl_version_string(); /* likewise */ + +e = AKSL_VERSION_CHECK(); /* compares the two; AKERR_VALUE on a mismatch */ +``` + +`AKSL_VERSION_CHECK()` is a macro on purpose: it expands at *your* call site, so +it captures the `AKSL_VERSION_*` you were built with and passes them to a +function that compares against the values baked into the library. Calling +`aksl_version_check()` with hand-written numbers defeats the whole mechanism. + +Compatibility is defined as "same soname", so pre-1.0 both major and minor must +match and the patch level is ignored — a caller built against 0.1.0 keeps working +against 0.1.7, which is exactly the promise the shared soname makes. + +In normal use the soname catches the mismatch first, at load time, and the check +never fires. It earns its keep when the soname is bypassed: a hand-install that +drops a 0.2.0 build in under the 0.1 filename, or a package that strips +versioning. Then the loader is happy and only the check notices: + +``` +compiled against : 0.1.0 (soname 0.1) +loaded : 0.2.0 (0.2.0) +MISMATCH DETECTED: compiled against libakstdlib 0.1.0, loaded 0.2.0 (soname 0.2) +``` + ### The libakerror version floor **libakerror 1.0.0 or newer is required.** That release made the status-name @@ -182,8 +246,8 @@ cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \ -DAKSL_COVERAGE_THRESHOLD=90 -DAKSL_COVERAGE_BRANCH_THRESHOLD=40 ``` -**Where it stands.** `src/stdlib.c` is at **99.0% of lines (200/202)**, **44.3% of -branches (481/1087)** and **21/21 functions**, so 90/40 above is a ratchet with +**Where it stands.** `src/stdlib.c` is at **99.1% of lines (217/219)**, **45.1% of +branches (519/1151)** and **25/25 functions**, so 90/40 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 @@ -197,8 +261,10 @@ stack-trace buffer limits, `akerr_valid_error_address` failures — and belong t libakerror's own suite rather than to this one. The libakerror 1.0.0 bump made that gap wider without changing a line of this library: the branch denominator went from 661 to 1087 as those macros grew, so the same tests that scored 51.0% -(337/661) now score 44.3% (481/1087). The gate moved 45 → 40 to match; line and -function coverage did not move at all. +(337/661) dropped to 44.3% (481/1087). The gate moved 45 → 40 to match; line and +function coverage did not move at all. Adding the `aksl_version_*` family and its +tests brought the figure back to 45.1% (519/1151), but the gate stays at 40 — +0.1 points of headroom is not a ratchet. 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 diff --git a/TODO.md b/TODO.md index a26d58f..c421196 100644 --- a/TODO.md +++ b/TODO.md @@ -406,14 +406,19 @@ cue to move it into `AKSL_TESTS`. 15. **`aksl_TreeNode.parent` is declared but never set or read** by any library function. -16. **Header hygiene**: no `extern "C" { }` guard for C++ consumers; no version macros - (`AKSL_VERSION_MAJOR`…); `akstdlib.h` pulls in `stdio.h`/`stdlib.h`/`string.h`/`stdint.h` - into every consumer's namespace. +16. **Header hygiene**: no `extern "C" { }` guard for C++ consumers; `akstdlib.h` pulls in + `stdio.h`/`stdlib.h`/`string.h`/`stdint.h` into every consumer's namespace. The version + macros this item also asked for are **done**: `AKSL_VERSION_MAJOR`/`_MINOR`/`_PATCH`/ + `_STRING`/`_NUMBER`/`_SONAME` are generated into `akstdlib_version.h` from + `project(akstdlib VERSION …)`, with `aksl_version()`/`aksl_version_string()`/ + `aksl_version_soname()` and `AKSL_VERSION_CHECK()` reporting the *loaded* library so a + header/`.so` mismatch is nameable. See `tests/test_version.c` and `README.md`. -17. **Doxygen coverage is 2 functions out of 20.** A `Doxyfile` exists but only +17. **Doxygen coverage is 2 functions out of 24.** A `Doxyfile` exists but only `aksl_tree_iterate` and `aksl_list_iterate` have doc comments. Every public function needs `@param`/`@throws`/`@return`, especially the ones whose contract deviates from libc - (`aksl_free(NULL)` is an error; `aksl_atoi` will become strict). + (`aksl_free(NULL)` is an error; `aksl_atoi` will become strict). The four + `aksl_version_*` entry points have prose comments in the header but no Doxygen tags. ### 2.3 Build, CI and repository @@ -456,15 +461,26 @@ cue to move it into `AKSL_TESTS`. would mean testing libakerror's macros rather than this library; that belongs to libakerror's mutation suite, since macros expand at the call site and coverage cannot see them properly from either side. Revisit if the gap ever hides a real regression. -- [ ] **`project()` declares no `VERSION`**, so `@PROJECT_VERSION@` expands to nothing and - the installed `akstdlib.pc` ships an empty `Version:` field — a consumer cannot pin - this library the way `akstdlib.pc` now pins `akerror >= 1.0.0`. libakstdlib also has - no `SOVERSION`, so `libakstdlib.so` carries no soname even though its public header - re-exports libakerror's ABI and therefore breaks whenever libakerror's does. Give the - project a version and a `SOVERSION`, and install an `akstdlibConfigVersion.cmake` via - `write_basic_package_version_file()` so `find_package(akstdlib 1.2 REQUIRED)` can work. - (libakerror has the same `ConfigVersion` gap, which is why - `cmake/akstdlib.cmake.in` calls `find_dependency(akerror)` with no version.) +- [x] **`project()` declared no `VERSION`**, so `@PROJECT_VERSION@` expanded to nothing, the + installed `akstdlib.pc` shipped an empty `Version:` field, and `libakstdlib.so` carried + no soname. Now `project(akstdlib VERSION 0.1.0)`, with `SOVERSION` `0.1` + (`MAJOR.MINOR` while major is 0, `MAJOR` from 1.0 — the `if()` in `CMakeLists.txt` and + the matching `#if` in `tests/test_version.c` encode the rule and are tested against + each other), an `akstdlibConfigVersion.cmake` at `SameMinorVersion`, and version macros + generated into `akstdlib_version.h`. Verified: `find_package(akstdlib 0.1)` is accepted + while `0.2` and `1.0` are refused, and a 0.2.0 build dropped in under the 0.1 filename + is caught by `AKSL_VERSION_CHECK()`. +- [ ] **libakerror still installs no `akerrorConfigVersion.cmake`**, so + `cmake/akstdlib.cmake.in` has to call `find_dependency(akerror)` with no version — a + request for one would be refused for want of a version file regardless of what is + installed. libakstdlib now does this correctly and libakerror does not; fix it there + with the same `write_basic_package_version_file()` call, then add the `1.0.0` floor to + the `find_dependency` here. Until then the floor rests on `akstdlib.pc`'s `Requires:` + and the `#error` guard in `akstdlib.h`. +- [ ] **`aksl_version_check()` ignores its `patch` argument** (`src/stdlib.c`, the `(void)patch`), + which is correct for the current "same soname" rule but means the parameter exists only + so the error message can name the caller's full version. If a future rule needs patch + to participate, that is the line to change. - [ ] **CI does not build against the submodule it pins.** `.gitea/workflows/ci.yaml` clones `libakerror@main` and installs it, while the build it then runs is top-level and so compiles `deps/libakerror` at the pinned commit — two different libakerror versions diff --git a/include/akstdlib.h b/include/akstdlib.h index 140cb8b..6eee423 100644 --- a/include/akstdlib.h +++ b/include/akstdlib.h @@ -21,6 +21,14 @@ #error "libakstdlib requires libakerror >= 1.0.0: the akerror.h on the include path predates the status registry. Rebuild and reinstall libakerror." #endif +/* + * AKSL_VERSION_MAJOR/MINOR/PATCH/STRING/NUMBER and AKSL_VERSION_SONAME. + * Generated by CMake from include/akstdlib_version.h.in, which is why there is + * no such file in the source tree -- it is configured into the build tree and + * installed alongside this header. + */ +#include + #include #include #include @@ -52,6 +60,42 @@ typedef akerr_ErrorContext AKERR_NOIGNORE *(*aksl_TreeNodeIterator)(aksl_TreeNod typedef akerr_ErrorContext AKERR_NOIGNORE *(*aksl_AllocFunc)(size_t size, void **dest); typedef akerr_ErrorContext AKERR_NOIGNORE *(*aksl_FreeFunc)(void *ptr); +/* + * Version of the shared library actually loaded, as opposed to the + * AKSL_VERSION_* macros above, which record what the caller was *compiled* + * against. The two differ exactly when a stale libakstdlib.so is on the loader + * path, which is the failure these entry points exist to name. + * + * All three pointers are required; this library treats a NULL out-param as a + * caller error rather than as "don't care" (so does aksl_free). + */ +akerr_ErrorContext AKERR_NOIGNORE *aksl_version(int *major, int *minor, int *patch); + +/* + * The loaded library's version as "MAJOR.MINOR.PATCH", and its soname. Neither + * can fail -- they return pointers to string literals in the library's own + * .rodata -- so they return the string directly rather than an error context, + * the same exception akerr_name_for_status() makes for the same reason. + */ +const char *aksl_version_string(void); +const char *aksl_version_soname(void); + +/* + * Compare the caller's compiled-in version against the loaded library's. + * Compatibility is defined as "same soname", so pre-1.0 both major and minor + * must match and patch is ignored; from 1.0 only major will matter. Raises + * AKERR_VALUE naming both versions on a mismatch. + * + * Call it through AKSL_VERSION_CHECK() below rather than directly. The macro + * expands at *your* call site, so it captures the AKSL_VERSION_* the caller was + * built with; the function compares them against the values baked into the + * library. Passing the numbers by hand defeats the entire mechanism. + */ +akerr_ErrorContext AKERR_NOIGNORE *aksl_version_check(int major, int minor, int patch); + +#define AKSL_VERSION_CHECK() \ + aksl_version_check(AKSL_VERSION_MAJOR, AKSL_VERSION_MINOR, AKSL_VERSION_PATCH) + akerr_ErrorContext AKERR_NOIGNORE *aksl_fopen(char *pathname, char *mode, FILE **fp); akerr_ErrorContext AKERR_NOIGNORE *aksl_fread(void *ptr, size_t size, size_t nmemb, FILE *stream); akerr_ErrorContext AKERR_NOIGNORE *aksl_fwrite(void *ptr, size_t size, size_t nmemb, FILE *fp); diff --git a/include/akstdlib_version.h.in b/include/akstdlib_version.h.in new file mode 100644 index 0000000..4b3d110 --- /dev/null +++ b/include/akstdlib_version.h.in @@ -0,0 +1,37 @@ +#ifndef _AKSTDLIB_VERSION_H_ +#define _AKSTDLIB_VERSION_H_ + +/* + * GENERATED FILE -- do not edit. + * + * Configured from include/akstdlib_version.h.in by CMake. The version itself + * lives in exactly one place, the project() call in CMakeLists.txt, and flows + * from there into these macros, the shared library's SOVERSION, the Version: + * field in akstdlib.pc, and akstdlibConfigVersion.cmake. To bump the version, + * edit project(); to change what the macros look like, edit this template. + */ + +#define AKSL_VERSION_MAJOR @PROJECT_VERSION_MAJOR@ +#define AKSL_VERSION_MINOR @PROJECT_VERSION_MINOR@ +#define AKSL_VERSION_PATCH @PROJECT_VERSION_PATCH@ +#define AKSL_VERSION_STRING "@PROJECT_VERSION@" + +/* + * A single ordered integer, for `#if AKSL_VERSION_NUMBER >= ...` compares. + * Computed rather than written out as a literal on purpose: a literal like + * 000100 is octal in C, which would silently make version 0.1.0 compare as 64. + * Each component gets two decimal digits, so this is correct up to x.99.99. + */ +#define AKSL_VERSION_NUMBER \ + ((AKSL_VERSION_MAJOR * 10000) + (AKSL_VERSION_MINOR * 100) + AKSL_VERSION_PATCH) + +/* + * The soname of the library these headers describe -- "0.1" for libakstdlib.so.0.1. + * This is the granularity at which the ABI is allowed to break, and it is what + * aksl_version_check() compares: pre-1.0 a minor bump is a break, so the soname + * carries MAJOR.MINOR. From 1.0 it becomes MAJOR alone, and the SOVERSION + * expression in CMakeLists.txt and this macro change together. + */ +#define AKSL_VERSION_SONAME "@AKSL_SOVERSION@" + +#endif // _AKSTDLIB_VERSION_H_ diff --git a/src/stdlib.c b/src/stdlib.c index c314e64..d3727ec 100644 --- a/src/stdlib.c +++ b/src/stdlib.c @@ -6,6 +6,57 @@ #include #include +/* + * Version of the library itself. These read the AKSL_VERSION_* macros as they + * were when *this translation unit* was compiled, which is what makes them the + * loaded library's version rather than the caller's: the caller's copy of the + * same macros comes from whatever akstdlib_version.h it compiled against. + */ +akerr_ErrorContext AKERR_NOIGNORE *aksl_version(int *major, int *minor, int *patch) +{ + PREPARE_ERROR(e); + FAIL_ZERO_RETURN(e, major, AKERR_NULLPOINTER, "major=%p, minor=%p, patch=%p", + (void *)major, (void *)minor, (void *)patch); + FAIL_ZERO_RETURN(e, minor, AKERR_NULLPOINTER, "major=%p, minor=%p, patch=%p", + (void *)major, (void *)minor, (void *)patch); + FAIL_ZERO_RETURN(e, patch, AKERR_NULLPOINTER, "major=%p, minor=%p, patch=%p", + (void *)major, (void *)minor, (void *)patch); + *major = AKSL_VERSION_MAJOR; + *minor = AKSL_VERSION_MINOR; + *patch = AKSL_VERSION_PATCH; + SUCCEED_RETURN(e); +} + +const char *aksl_version_string(void) +{ + return AKSL_VERSION_STRING; +} + +const char *aksl_version_soname(void) +{ + return AKSL_VERSION_SONAME; +} + +/* + * Compatibility is "same soname". Pre-1.0 that is MAJOR.MINOR, so both are + * compared and patch is deliberately ignored; from 1.0 the minor comparison + * comes out, in step with the SOVERSION expression in CMakeLists.txt. The + * caller's numbers arrive as arguments because AKSL_VERSION_CHECK() expanded + * them at the caller's site -- see the macro in akstdlib.h. + */ +akerr_ErrorContext AKERR_NOIGNORE *aksl_version_check(int major, int minor, int patch) +{ + PREPARE_ERROR(e); + (void)patch; + FAIL_NONZERO_RETURN(e, + (major != AKSL_VERSION_MAJOR || minor != AKSL_VERSION_MINOR), + AKERR_VALUE, + "compiled against libakstdlib %d.%d.%d, loaded %s (soname %s)", + major, minor, patch, + AKSL_VERSION_STRING, AKSL_VERSION_SONAME); + SUCCEED_RETURN(e); +} + akerr_ErrorContext AKERR_NOIGNORE *aksl_malloc(size_t size, void **dst) { PREPARE_ERROR(e); diff --git a/tests/test_version.c b/tests/test_version.c new file mode 100644 index 0000000..53177d0 --- /dev/null +++ b/tests/test_version.c @@ -0,0 +1,177 @@ +/* + * Version reporting -- TODO.md section 2.2.16. + * + * There are two versions in play and the whole point of this API is that they + * are allowed to differ: + * + * the AKSL_VERSION_* macros what the caller was COMPILED against, baked + * into the caller's object file from whatever + * akstdlib_version.h was on its include path + * aksl_version() and friends what is actually LOADED, baked into + * libakstdlib.so when the library was built + * + * In this test they necessarily agree, because the test binary and the library + * are built from one tree in one configure. So the assertions below split into + * two kinds: the ones that check the two sides agree (which would catch a build + * that compiled the library and its tests against different generated headers), + * and the ones that drive aksl_version_check() with deliberately wrong numbers + * to prove it actually refuses a mismatch rather than always returning success. + * The second kind is what makes the first kind worth anything. + */ + +#include "aksl_capture.h" + +static int test_version_string_matches_its_components(void) +{ + char expected[64]; + + snprintf(expected, sizeof(expected), "%d.%d.%d", + AKSL_VERSION_MAJOR, AKSL_VERSION_MINOR, AKSL_VERSION_PATCH); + AKSL_CHECK(strcmp(AKSL_VERSION_STRING, expected) == 0); + return 0; +} + +/* + * AKSL_VERSION_NUMBER must be ordinary decimal arithmetic. Written out as a + * literal it would be tempting to spell 0.1.0 as 000100, which C reads as + * octal 64 -- so this compares the compile-time macro against the same figure + * computed at runtime from the loaded library's own components. A literal that + * went octal fails here; the current computed form cannot. + */ +static int test_version_number_is_decimal_and_ordered(void) +{ + int major = -1; + int minor = -1; + int patch = -1; + + AKSL_CHECK_OK(aksl_version(&major, &minor, &patch)); + AKSL_CHECK(AKSL_VERSION_NUMBER == (major * 10000) + (minor * 100) + patch); + + /* Usable in the preprocessor, which is the reason it exists at all. */ +#if AKSL_VERSION_NUMBER < 0 + AKSL_CHECK(0 && "AKSL_VERSION_NUMBER is negative"); +#endif + AKSL_CHECK(AKSL_VERSION_NUMBER >= 0); + return 0; +} + +/* Header and shared library came out of the same configure. */ +static int test_loaded_version_matches_the_header(void) +{ + int major = -1; + int minor = -1; + int patch = -1; + + AKSL_CHECK_OK(aksl_version(&major, &minor, &patch)); + AKSL_CHECK(major == AKSL_VERSION_MAJOR); + AKSL_CHECK(minor == AKSL_VERSION_MINOR); + AKSL_CHECK(patch == AKSL_VERSION_PATCH); + + AKSL_CHECK(strcmp(aksl_version_string(), AKSL_VERSION_STRING) == 0); + AKSL_CHECK(strcmp(aksl_version_soname(), AKSL_VERSION_SONAME) == 0); + return 0; +} + +/* + * The soname is the ABI-break granularity, and it mirrors the SOVERSION + * expression in CMakeLists.txt: MAJOR.MINOR while major is 0, MAJOR alone after + * that. If one of the two ever changes without the other, this fails. + */ +static int test_soname_matches_the_documented_rule(void) +{ + char expected[64]; + +#if AKSL_VERSION_MAJOR == 0 + snprintf(expected, sizeof(expected), "%d.%d", + AKSL_VERSION_MAJOR, AKSL_VERSION_MINOR); +#else + snprintf(expected, sizeof(expected), "%d", AKSL_VERSION_MAJOR); +#endif + AKSL_CHECK(strcmp(aksl_version_soname(), expected) == 0); + return 0; +} + +static int test_version_check_accepts_a_matching_build(void) +{ + AKSL_CHECK_OK(AKSL_VERSION_CHECK()); + return 0; +} + +/* + * The refusals. Without these the success above proves nothing -- a + * aksl_version_check() that returned NULL unconditionally would pass it. + */ +static int test_version_check_refuses_a_mismatch(void) +{ + AKSL_CHECK_STATUS(aksl_version_check(AKSL_VERSION_MAJOR + 1, + AKSL_VERSION_MINOR, + AKSL_VERSION_PATCH), + AKERR_VALUE); + AKSL_CHECK_STATUS(aksl_version_check(AKSL_VERSION_MAJOR, + AKSL_VERSION_MINOR + 1, + AKSL_VERSION_PATCH), + AKERR_VALUE); + return 0; +} + +/* + * Patch is deliberately not part of the comparison: a patch bump is an ABI + * promise, so a caller built against 0.1.0 must keep working against 0.1.7. + */ +static int test_version_check_ignores_the_patch_level(void) +{ + AKSL_CHECK_OK(aksl_version_check(AKSL_VERSION_MAJOR, + AKSL_VERSION_MINOR, + AKSL_VERSION_PATCH + 9)); + return 0; +} + +/* The error has to say which two versions disagree, or it is not actionable. */ +static int test_mismatch_message_names_both_versions(void) +{ + char compiled[64]; + + snprintf(compiled, sizeof(compiled), "%d.%d.%d", + AKSL_VERSION_MAJOR + 1, AKSL_VERSION_MINOR, AKSL_VERSION_PATCH); + + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_version_check(AKSL_VERSION_MAJOR + 1, + AKSL_VERSION_MINOR, + AKSL_VERSION_PATCH), + AKERR_VALUE, compiled); + AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_version_check(AKSL_VERSION_MAJOR + 1, + AKSL_VERSION_MINOR, + AKSL_VERSION_PATCH), + AKERR_VALUE, AKSL_VERSION_STRING); + return 0; +} + +static int test_rejects_null_arguments(void) +{ + int major = 0; + int minor = 0; + int patch = 0; + + AKSL_CHECK_STATUS(aksl_version(NULL, &minor, &patch), AKERR_NULLPOINTER); + AKSL_CHECK_STATUS(aksl_version(&major, NULL, &patch), AKERR_NULLPOINTER); + AKSL_CHECK_STATUS(aksl_version(&major, &minor, NULL), AKERR_NULLPOINTER); + return 0; +} + +int main(void) +{ + int failures = 0; + + akerr_init(); + + AKSL_RUN(failures, test_version_string_matches_its_components); + AKSL_RUN(failures, test_version_number_is_decimal_and_ordered); + AKSL_RUN(failures, test_loaded_version_matches_the_header); + AKSL_RUN(failures, test_soname_matches_the_documented_rule); + AKSL_RUN(failures, test_version_check_accepts_a_matching_build); + AKSL_RUN(failures, test_version_check_refuses_a_mismatch); + AKSL_RUN(failures, test_version_check_ignores_the_patch_level); + AKSL_RUN(failures, test_mismatch_message_names_both_versions); + AKSL_RUN(failures, test_rejects_null_arguments); + + AKSL_REPORT(failures); +}