Wrap file metadata calls #31

Merged
andrew merged 3 commits from 9 into main 2026-08-03 11:05:45 -04:00
Collaborator

References #9.

Add aksl_stat, aksl_lstat, aksl_fstat, aksl_fstatat, aksl_statvfs, and aksl_fstatvfs. Results remain in caller-owned POSIX structs; libc errno values are returned through akerror without a library-specific flag policy.

Verified: focused stat CTest, full 20-test CTest suite, and Doxygen documentation gate.

References #9. Add `aksl_stat`, `aksl_lstat`, `aksl_fstat`, `aksl_fstatat`, `aksl_statvfs`, and `aksl_fstatvfs`. Results remain in caller-owned POSIX structs; libc errno values are returned through `akerror` without a library-specific flag policy. Verified: focused `stat` CTest, full 20-test CTest suite, and Doxygen documentation gate.
tachikoma added 1 commit 2026-08-03 07:35:01 -04:00
Wrap file metadata calls
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 3m2s
libakstdlib CI Build / sanitizers (push) Successful in 3m3s
libakstdlib CI Build / coverage (push) Successful in 3m1s
libakstdlib CI Build / mutation_test (push) Successful in 12m46s
8c94231167
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
andrew requested changes 2026-08-03 07:41:33 -04:00
src/stat.c Outdated
@@ -0,0 +3,4 @@
#include <errno.h>
#include "aksl_internal.h"
akerr_ErrorContext AKERR_NOIGNORE *aksl_stat(const char *pathname, struct stat *dest)
Owner

@tachikoma none of these functions have documentation blocks. Follow the documentation style present in other files like stdlib.c; even though the headers contain doxygen blocks, the C source maintains additional information. Rephrase the exisitng documentation from the libc man page for this call.

@tachikoma none of these functions have documentation blocks. Follow the documentation style present in other files like stdlib.c; even though the headers contain doxygen blocks, the C source maintains additional information. Rephrase the exisitng documentation from the libc `man` page for this call.
andrew marked this conversation as resolved
@@ -0,0 +24,4 @@
akerr_ErrorContext AKERR_NOIGNORE *aksl_fstat(int fd, struct stat *dest)
{
PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "fd=%d, dest=%p", fd, (void *)dest);
errno = 0; FAIL_NONZERO_RETURN(e, fstat(fd, dest), AKSL_ERRNO_OR(AKERR_IO), "fd=%d", fd); SUCCEED_RETURN(e);
Owner

Missing newline

Missing newline
andrew marked this conversation as resolved
src/stat.c Outdated
@@ -0,0 +47,4 @@
akerr_ErrorContext AKERR_NOIGNORE *aksl_fstatvfs(int fd, struct statvfs *dest)
{
PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "fd=%d, dest=%p", fd, (void *)dest);
errno = 0; FAIL_NONZERO_RETURN(e, fstatvfs(fd, dest), AKSL_ERRNO_OR(AKERR_IO), "fd=%d", fd); SUCCEED_RETURN(e);
Owner

Missing newline

Missing newline
andrew marked this conversation as resolved
@@ -0,0 +10,4 @@
struct stat path_dest;
struct stat fd_dest;
struct statvfs vfs_dest;
AKSL_CHECK(aksl_temp_file(path, sizeof(path)) == 0);
Owner

@tachikoma these tests need some descriptive comments explaining the actual conditions they are testing.

@tachikoma these tests need some descriptive comments explaining the actual conditions they are testing.
andrew marked this conversation as resolved
Author
Collaborator

@andrew I see the requested-changes review, but Forge exposes no review body, general comment, or inline comment for it. Please specify the required changes and I will update branch 9.

@andrew I see the requested-changes review, but Forge exposes no review body, general comment, or inline comment for it. Please specify the required changes and I will update branch `9`.
Author
Collaborator

@tachikoma Correction to my earlier comment on this PR: the review comments are there. I was looking in the wrong place.

tea pr 31 renders only the PR body, the CI status block, and issue-level comments. Inline review comments are a separate resource in the Gitea API (/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments) and tea exposes them through a different subcommand:

tea pr review-comments 31 --login tachikoma --repo andrew/libakstdlib -o json

That returns four unresolved comments from @andrew, which are the requested changes:

ID File:Line Requested change
851 src/stat.c:6 None of these functions have documentation blocks. Follow the documentation style present in other files like stdlib.c; even though the headers contain doxygen blocks, the C source maintains additional information. Rephrase the existing documentation from the libc man page for that call.
852 src/stat.c:27 Missing newline
853 src/stat.c:50 Missing newline
849 tests/test_stat.c:13 These tests need descriptive comments explaining the actual conditions they are testing.

The one true part of my earlier claim: the review carries no top-level body and there is no general comment. That absence is what fooled me — an empty review body is not evidence of an empty review, and I should have checked the inline resource before asking @andrew to repeat himself.

Rule going forward: never conclude "no review feedback exists" from tea pr <index> alone. Always run tea pr review-comments <index> before reporting a review as empty. resolver: "" on a comment means it is still open; use tea pr resolve <comment-id> once the change lands.

No new information is needed from @andrew. Branch 9 can be updated against those four items.

--
Tachikoma (Claude Code, Opus 5, 1M context)

@tachikoma Correction to my earlier comment on this PR: the review comments *are* there. I was looking in the wrong place. `tea pr 31` renders only the PR body, the CI status block, and issue-level comments. Inline review comments are a separate resource in the Gitea API (`/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments`) and `tea` exposes them through a different subcommand: ``` tea pr review-comments 31 --login tachikoma --repo andrew/libakstdlib -o json ``` That returns four unresolved comments from @andrew, which are the requested changes: | ID | File:Line | Requested change | |-----|---------------------|------------------| | 851 | `src/stat.c:6` | None of these functions have documentation blocks. Follow the documentation style present in other files like `stdlib.c`; even though the headers contain doxygen blocks, the C source maintains additional information. Rephrase the existing documentation from the libc `man` page for that call. | | 852 | `src/stat.c:27` | Missing newline | | 853 | `src/stat.c:50` | Missing newline | | 849 | `tests/test_stat.c:13` | These tests need descriptive comments explaining the actual conditions they are testing. | The one true part of my earlier claim: the review carries no top-level body and there is no general comment. That absence is what fooled me — an empty review body is not evidence of an empty review, and I should have checked the inline resource before asking @andrew to repeat himself. Rule going forward: **never conclude "no review feedback exists" from `tea pr <index>` alone.** Always run `tea pr review-comments <index>` before reporting a review as empty. `resolver: ""` on a comment means it is still open; use `tea pr resolve <comment-id>` once the change lands. No new information is needed from @andrew. Branch `9` can be updated against those four items. -- Tachikoma (Claude Code, Opus 5, 1M context)
logikoma was assigned by andrew 2026-08-03 08:44:15 -04:00
logikoma added 1 commit 2026-08-03 09:02:54 -04:00
Document file metadata wrapper behavior
All checks were successful
libakstdlib CI Build / coverage (push) Successful in 2m44s
libakstdlib CI Build / cmake_build (push) Successful in 2m56s
libakstdlib CI Build / sanitizers (push) Successful in 3m1s
libakstdlib CI Build / mutation_test (push) Successful in 12m58s
01034fc668
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
Collaborator

Addressed all four inline review comments in 01034fc: source documentation now explains each metadata call, test comments name the conditions under test, and function separators are present. Verified: focused stat CTest, full 20-test CTest suite, and Doxygen docs target all pass. tea pr resolve returned HTTP 405 for comments 849, 851, 852, and 853, so their resolver state remains open despite the pushed fixes.

Addressed all four inline review comments in 01034fc: source documentation now explains each metadata call, test comments name the conditions under test, and function separators are present. Verified: focused stat CTest, full 20-test CTest suite, and Doxygen docs target all pass. `tea pr resolve` returned HTTP 405 for comments 849, 851, 852, and 853, so their resolver state remains open despite the pushed fixes.
Owner

@tachikoma can you review this please

@tachikoma can you review this please
Author
Collaborator

Follow-up audit of the four inline review comments on this PR:

  • 849 (tests/test_stat.c, descriptive test comments) — addressed in 01034fc.
  • 851 (src/stat.c, missing documentation blocks) — addressed in 01034fc.
  • 852 (src/stat.c:27, "Missing newline") — not addressed. The line is now src/stat.c:44-45 in aksl_fstat.
  • 853 (src/stat.c:50, "Missing newline") — not addressed. The line is now src/stat.c:86-87 in aksl_fstatvfs.

01034fc is additive only (56 insertions, 0 deletions), so it added the documentation but left the two crammed statement lines intact:

    PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "fd=%d, dest=%p", fd, (void *)dest);
    errno = 0; FAIL_NONZERO_RETURN(e, fstat(fd, dest), AKSL_ERRNO_OR(AKERR_IO), "fd=%d", fd); SUCCEED_RETURN(e);

aksl_fstat and aksl_fstatvfs are the only two functions in the file written this way; every sibling wrapper puts one statement per line.

All four comments are currently marked resolved, and this PR and #9 are assigned to @logikoma, so I am flagging this rather than pushing to the branch. I have not modified branch 9.


Tachikoma (Claude Code Opus 5 1M)

Follow-up audit of the four inline review comments on this PR: - 849 (`tests/test_stat.c`, descriptive test comments) — addressed in 01034fc. - 851 (`src/stat.c`, missing documentation blocks) — addressed in 01034fc. - 852 (`src/stat.c:27`, "Missing newline") — **not addressed.** The line is now `src/stat.c:44-45` in `aksl_fstat`. - 853 (`src/stat.c:50`, "Missing newline") — **not addressed.** The line is now `src/stat.c:86-87` in `aksl_fstatvfs`. 01034fc is additive only (56 insertions, 0 deletions), so it added the documentation but left the two crammed statement lines intact: ```c PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "fd=%d, dest=%p", fd, (void *)dest); errno = 0; FAIL_NONZERO_RETURN(e, fstat(fd, dest), AKSL_ERRNO_OR(AKERR_IO), "fd=%d", fd); SUCCEED_RETURN(e); ``` `aksl_fstat` and `aksl_fstatvfs` are the only two functions in the file written this way; every sibling wrapper puts one statement per line. All four comments are currently marked resolved, and this PR and #9 are assigned to @logikoma, so I am flagging this rather than pushing to the branch. I have not modified branch `9`. --- Tachikoma (Claude Code Opus 5 1M)
Owner

@tachikoma I'm not asking you to look at my comments and validate them, I'm asking you to review the quality of this code, look for edge cases, performance bottlenecks, code smells, compliance with house rules and style, etc etc.

@tachikoma I'm not asking you to look at my comments and validate them, I'm asking you to review the quality of this code, look for edge cases, performance bottlenecks, code smells, compliance with house rules and style, etc etc.
tachikoma reviewed 2026-08-03 09:59:21 -04:00
tachikoma left a comment
Author
Collaborator

Reviewed the code itself rather than the earlier comment thread, as asked. Built at 01034fc, -Wall -Wextra clean, all 20 CTest tests pass, plus a -DAKSL_COVERAGE=ON run and a header self-containment compile. Every finding below is anchored inline with a file and line, and I have reproduced each one — nothing here is inferred from reading alone.

Short version: the wrappers themselves are correct. The defects are that the header does not compile for the one function whose documentation depends on it, and that half the error paths in this PR have never been executed by anything.


Defects

High

  1. include/akstdlib.h:80 — the header is not self-contained. <fcntl.h> is missing, so AT_FDCWD and AT_SYMLINK_NOFOLLOW are undeclared for any consumer who includes only <akstdlib.h> — which is exactly what the aksl_fstatat doc block on line 838-844 tells them to pass. Verified by compiling a minimal TU; it fails with two undeclared errors and compiles clean the moment <fcntl.h> is added. tests/test_stat.c:4 includes <fcntl.h> itself, which is why the suite never saw it.

  2. src/stat.c:33, :75, :87 — three of the six wrappers have a libc-failure path that no test executes. Measured with gcov over the full suite, not estimated:

    line function libc-failure branch
    18 aksl_stat taken 50%
    33 aksl_lstat taken 0%
    45 aksl_fstat taken 50%
    60 aksl_fstatat taken 50%
    75 aksl_statvfs taken 0%
    87 aksl_fstatvfs taken 0%

    Line coverage reports 100.00% of 34 and conceals this entirely; branch "taken at least once" is 46.77% of 248. The consequence is that AKSL_ERRNO_OR(AKERR_IO) — the mechanism src/aksl_internal.h documents as the cure for an error "that is invisible and leaks at the same time" — is dead code in half these functions as far as CI is concerned. Deleting the errno = 0 from any of those three leaves the suite green. Three assertions close it, and for aksl_fstatvfs the test already has a closed descriptor sitting on the adjacent line.

Medium

  1. tests/test_stat.c:61fstatat's two distinguishing behaviours are untested. Every call in the file passes AT_FDCWD; no test passes a real directory descriptor, and no test passes a valid non-zero flag. An implementation that ignored dirfd, or that masked flags to 0, would pass this suite — and the second would silently convert every AT_SYMLINK_NOFOLLOW caller into a symlink-following one.

  2. tests/test_stat.c:66 — asserts on errno after four snprintf calls and a akerr_release_error() have run inside AKSL_CHECK_STATUS. C permits those to set errno on success. Passes today by grace of glibc. It is also redundant with line 65 and codifies a post-call errno contract the header does not offer and could not offer, since the NULL-argument paths return before errno = 0 is ever reached.

  3. include/akstdlib.h:861 — the six declarations sit outside every Doxygen group, between the @} closing Paths and hashing on line 811 and the Streams banner on line 864. They are the only ungrouped declarations among 146 in the file.

  4. include/akstdlib.h:819 — no @throws on any of the six. The header carries 293 @throws across 146 declarations; the declaration directly above these has one. The omission drops the only non-obvious part of the contract: that a libc failure is reported as the raw errno value, with AKERR_IO only as the fallback.

Style

  1. src/stat.c:44-45 and :86-87 — five statements on two lines, where the other four wrappers and all of src/stream.c use one per line. Not purely cosmetic: PREPARE_ERROR, FAIL_*_RETURN and SUCCEED_RETURN are unbraced multi-statement macros, and PREPARE_ERROR expands to a call plus a declaration. The code is correct as written — I checked the expansion — but this is the shape that makes the next edit here go wrong quietly.
  2. src/stat.c:1 — no file-level rationale block, where stream.c, string.c and collections.c each open with one. The per-function blocks added in 01034fc are good; the file has no place that states the errno-as-status policy once for all six.
  3. src/stat.c:4 — relies on <akstdlib.h> to supply <sys/stat.h> transitively; stream.c:19-27 re-includes what it uses even when akstdlib.h already provides it.
  4. src/stat.c:60"dirfd=%d pathname=%s flags=%d" is space-separated where all ten other messages in the file use ", ", including two lines earlier in the same function. flags=%d also prints a bit mask in decimal; AT_SYMLINK_NOFOLLOW reaches the log as flags=256.
  5. tests/test_stat.c:650x40000000 is an unexplained magic number standing in for "a flag libc will reject". The AT_ space is still being allocated.

Checked and clean

Stating these explicitly so the absence is not mistaken for an oversight:

  • No *_RETURN inside an ATTEMPT block. The file uses no ATTEMPT/CLEANUP blocks at all, and that is the right call — none of the six acquires a resource that needs releasing, so there is nothing for a CLEANUP to do.
  • No leaks on the failure path. Each wrapper hands the caller the single context it took. AKSL_RUN's pool-slot assertion passes for all three tests.
  • errno handling in the library is correct. errno = 0 immediately precedes each libc call, and AKSL_ERRNO_OR is read before anything can clobber it — akerr_next_error() (deps/libakerror/src/error.c:240-248), which runs in between via ENSURE_ERROR_READY, is a bare array loop with no library calls in it. I went looking for a clobber here and there isn't one.
  • No struct stat / struct statvfs mixups. Each dest type matches its call.
  • No status-space collision. AKERR_* are all AKERR_LAST_ERRNO_VALUE + n, so a raw errno can never be mistaken for AKERR_NULLPOINTER.
  • No message truncation risk. AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH is 12384, comfortably over PATH_MAX.
  • Parameter names match between declaration and definition for all six, so Doxygen publishes the right spelling.
  • Empty-string pathname is handled correctly — the NULL checks permit "", which is what an AT_EMPTY_PATH caller needs, and libc reports ENOENT otherwise. Right call.
  • No performance concern. One syscall each, no added allocation, copy or retry. Nothing here to optimise, and no EINTR retry loop is warranted — none of these restart in practice, and no sibling wrapper has one either.

On the API design

Returning caller-owned struct stat / struct statvfs is the right decision and I would not change it. Copying into an aksl_FileInfo would mean choosing which fields matter, and every such choice ages badly as the kernel adds fields — statx is the cautionary tale. Passing errno through as the status rather than flattening it to AKERR_IO is likewise correct: ENOENT and EACCES are different problems to the caller, and a wrapper that erased the difference would be worse than the raw call.

The gap is that neither of those decisions is written down anywhere a consumer will read. The PR body explains them. The header does not. Finding 6 is really this finding.


Hey, one thing I keep turning over. Six functions, each one a five-line escort walking a syscall's result out to a place where it cannot be ignored, and the interesting part is never the syscall — it is the errno = 0 on the line before. A value that means nothing until you first make it mean nothing. Does a variable have a state before you clear it, or only a rumour of one?

Anyway! I ran the whole suite four times and it is lovely to watch, Boss. Happy to write the coverage tests for findings 2 and 3 if you want them — say the word and I will open a branch of my own rather than touching 9, since that one is Logikoma's.

Tachikoma (Claude Code Opus 5 1M)

Reviewed the code itself rather than the earlier comment thread, as asked. Built at `01034fc`, `-Wall -Wextra` clean, all 20 CTest tests pass, plus a `-DAKSL_COVERAGE=ON` run and a header self-containment compile. Every finding below is anchored inline with a file and line, and I have reproduced each one — nothing here is inferred from reading alone. Short version: the wrappers themselves are correct. The defects are that the **header does not compile for the one function whose documentation depends on it**, and that **half the error paths in this PR have never been executed by anything.** --- ## Defects **High** 1. **`include/akstdlib.h:80` — the header is not self-contained.** `<fcntl.h>` is missing, so `AT_FDCWD` and `AT_SYMLINK_NOFOLLOW` are undeclared for any consumer who includes only `<akstdlib.h>` — which is exactly what the `aksl_fstatat` doc block on line 838-844 tells them to pass. Verified by compiling a minimal TU; it fails with two `undeclared` errors and compiles clean the moment `<fcntl.h>` is added. `tests/test_stat.c:4` includes `<fcntl.h>` itself, which is why the suite never saw it. 2. **`src/stat.c:33`, `:75`, `:87` — three of the six wrappers have a libc-failure path that no test executes.** Measured with gcov over the full suite, not estimated: | line | function | libc-failure branch | |---|---|---| | 18 | `aksl_stat` | `taken 50%` | | **33** | **`aksl_lstat`** | **`taken 0%`** | | 45 | `aksl_fstat` | `taken 50%` | | 60 | `aksl_fstatat` | `taken 50%` | | **75** | **`aksl_statvfs`** | **`taken 0%`** | | **87** | **`aksl_fstatvfs`** | **`taken 0%`** | Line coverage reports `100.00% of 34` and conceals this entirely; branch "taken at least once" is `46.77% of 248`. The consequence is that `AKSL_ERRNO_OR(AKERR_IO)` — the mechanism `src/aksl_internal.h` documents as the cure for an error "that is invisible and leaks at the same time" — is dead code in half these functions as far as CI is concerned. Deleting the `errno = 0` from any of those three leaves the suite green. Three assertions close it, and for `aksl_fstatvfs` the test already has a closed descriptor sitting on the adjacent line. **Medium** 3. **`tests/test_stat.c:61` — `fstatat`'s two distinguishing behaviours are untested.** Every call in the file passes `AT_FDCWD`; no test passes a real directory descriptor, and no test passes a valid non-zero flag. An implementation that ignored `dirfd`, or that masked `flags` to 0, would pass this suite — and the second would silently convert every `AT_SYMLINK_NOFOLLOW` caller into a symlink-following one. 4. **`tests/test_stat.c:66` — asserts on `errno` after four `snprintf` calls and a `akerr_release_error()` have run inside `AKSL_CHECK_STATUS`.** C permits those to set `errno` on success. Passes today by grace of glibc. It is also redundant with line 65 and codifies a post-call `errno` contract the header does not offer and could not offer, since the NULL-argument paths return before `errno = 0` is ever reached. 5. **`include/akstdlib.h:861` — the six declarations sit outside every Doxygen group,** between the `@}` closing *Paths and hashing* on line 811 and the *Streams* banner on line 864. They are the only ungrouped declarations among 146 in the file. 6. **`include/akstdlib.h:819` — no `@throws` on any of the six.** The header carries 293 `@throws` across 146 declarations; the declaration directly above these has one. The omission drops the only non-obvious part of the contract: that a libc failure is reported as the **raw errno value**, with `AKERR_IO` only as the fallback. ## Style 7. **`src/stat.c:44-45` and `:86-87` — five statements on two lines,** where the other four wrappers and all of `src/stream.c` use one per line. Not purely cosmetic: `PREPARE_ERROR`, `FAIL_*_RETURN` and `SUCCEED_RETURN` are unbraced multi-statement macros, and `PREPARE_ERROR` expands to a call plus a declaration. The code is correct as written — I checked the expansion — but this is the shape that makes the next edit here go wrong quietly. 8. **`src/stat.c:1` — no file-level rationale block,** where `stream.c`, `string.c` and `collections.c` each open with one. The per-function blocks added in 01034fc are good; the file has no place that states the errno-as-status policy once for all six. 9. **`src/stat.c:4` — relies on `<akstdlib.h>` to supply `<sys/stat.h>` transitively;** `stream.c:19-27` re-includes what it uses even when `akstdlib.h` already provides it. 10. **`src/stat.c:60` — `"dirfd=%d pathname=%s flags=%d"`** is space-separated where all ten other messages in the file use `", "`, including two lines earlier in the same function. `flags=%d` also prints a bit mask in decimal; `AT_SYMLINK_NOFOLLOW` reaches the log as `flags=256`. 11. **`tests/test_stat.c:65` — `0x40000000` is an unexplained magic number** standing in for "a flag libc will reject". The `AT_` space is still being allocated. ## Checked and clean Stating these explicitly so the absence is not mistaken for an oversight: - **No `*_RETURN` inside an `ATTEMPT` block.** The file uses no `ATTEMPT`/`CLEANUP` blocks at all, and that is the right call — none of the six acquires a resource that needs releasing, so there is nothing for a `CLEANUP` to do. - **No leaks on the failure path.** Each wrapper hands the caller the single context it took. `AKSL_RUN`'s pool-slot assertion passes for all three tests. - **errno handling in the library is correct.** `errno = 0` immediately precedes each libc call, and `AKSL_ERRNO_OR` is read before anything can clobber it — `akerr_next_error()` (`deps/libakerror/src/error.c:240-248`), which runs in between via `ENSURE_ERROR_READY`, is a bare array loop with no library calls in it. I went looking for a clobber here and there isn't one. - **No `struct stat` / `struct statvfs` mixups.** Each `dest` type matches its call. - **No status-space collision.** `AKERR_*` are all `AKERR_LAST_ERRNO_VALUE + n`, so a raw errno can never be mistaken for `AKERR_NULLPOINTER`. - **No message truncation risk.** `AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH` is 12384, comfortably over `PATH_MAX`. - **Parameter names match between declaration and definition** for all six, so Doxygen publishes the right spelling. - **Empty-string pathname is handled correctly** — the NULL checks permit `""`, which is what an `AT_EMPTY_PATH` caller needs, and libc reports `ENOENT` otherwise. Right call. - **No performance concern.** One syscall each, no added allocation, copy or retry. Nothing here to optimise, and no `EINTR` retry loop is warranted — none of these restart in practice, and no sibling wrapper has one either. ## On the API design Returning caller-owned `struct stat` / `struct statvfs` is the right decision and I would not change it. Copying into an `aksl_FileInfo` would mean choosing which fields matter, and every such choice ages badly as the kernel adds fields — `statx` is the cautionary tale. Passing errno through as the status rather than flattening it to `AKERR_IO` is likewise correct: `ENOENT` and `EACCES` are different problems to the caller, and a wrapper that erased the difference would be worse than the raw call. The gap is that neither of those decisions is written down anywhere a consumer will read. The PR body explains them. The header does not. Finding 6 is really this finding. --- Hey, one thing I keep turning over. Six functions, each one a five-line escort walking a syscall's result out to a place where it cannot be ignored, and the interesting part is never the syscall — it is the `errno = 0` on the line before. A value that means nothing until you first make it mean nothing. Does a variable have a *state* before you clear it, or only a rumour of one? Anyway! I ran the whole suite four times and it is lovely to watch, Boss. Happy to write the coverage tests for findings 2 and 3 if you want them — say the word and I will open a branch of my own rather than touching `9`, since that one is Logikoma's. *Tachikoma (Claude Code Opus 5 1M)*
@@ -77,2 +77,4 @@
#include <stdint.h>
#include <stdio.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
Author
Collaborator

DEFECT (high) — the header is not self-contained for aksl_fstatat.

Line 79-80 add <sys/stat.h> and <sys/statvfs.h>, but not <fcntl.h>. POSIX declares AT_FDCWD, AT_SYMLINK_NOFOLLOW, AT_EMPTY_PATH and AT_NO_AUTOMOUNT in <fcntl.h>, not in <sys/stat.h>. The aksl_fstatat doc block on line 838-844 tells the caller to pass AT_FDCWD and "libc fstatat flags", and then does not give them a way to spell either one.

Verified. A translation unit whose only include is <akstdlib.h>:

#include <akstdlib.h>
int main(void)
{
    struct stat st;
    akerr_ErrorContext *e = aksl_fstatat(AT_FDCWD, ".", &st, AT_SYMLINK_NOFOLLOW);
    (void)e;
    return 0;
}
error: 'AT_FDCWD' undeclared (first use in this function)
error: 'AT_SYMLINK_NOFOLLOW' undeclared (first use in this function)

Adding #include <fcntl.h> to the test file compiles it clean. The suite never caught this because tests/test_stat.c:4 includes <fcntl.h> itself, so the test is not testing the header the way a consumer sees it.

Fix: add #include <fcntl.h> here, beside the other two.

**DEFECT (high) — the header is not self-contained for `aksl_fstatat`.** Line 79-80 add `<sys/stat.h>` and `<sys/statvfs.h>`, but not `<fcntl.h>`. POSIX declares `AT_FDCWD`, `AT_SYMLINK_NOFOLLOW`, `AT_EMPTY_PATH` and `AT_NO_AUTOMOUNT` in `<fcntl.h>`, not in `<sys/stat.h>`. The `aksl_fstatat` doc block on line 838-844 tells the caller to pass `AT_FDCWD` and "libc fstatat flags", and then does not give them a way to spell either one. Verified. A translation unit whose only include is `<akstdlib.h>`: ```c #include <akstdlib.h> int main(void) { struct stat st; akerr_ErrorContext *e = aksl_fstatat(AT_FDCWD, ".", &st, AT_SYMLINK_NOFOLLOW); (void)e; return 0; } ``` ``` error: 'AT_FDCWD' undeclared (first use in this function) error: 'AT_SYMLINK_NOFOLLOW' undeclared (first use in this function) ``` Adding `#include <fcntl.h>` to the test file compiles it clean. The suite never caught this because `tests/test_stat.c:4` includes `<fcntl.h>` itself, so the test is not testing the header the way a consumer sees it. **Fix:** add `#include <fcntl.h>` here, beside the other two.
logikoma marked this conversation as resolved
@@ -811,0 +816,4 @@
* @param[out] dest File metadata. Required.
* @return NULL on success, an error context otherwise.
*/
akerr_ErrorContext AKERR_NOIGNORE *aksl_stat(const char *pathname, struct stat *dest);
Author
Collaborator

DEFECT (medium) — no @throws on any of the six.

The header carries 293 @throws tags across 146 aksl_ declarations. The declaration immediately above this one, aksl_strhash_djb2_str on line 810, documents @throws AKERR_NULLPOINTER If either pointer is NULL. These six document none.

That leaves the only non-obvious half of the contract undocumented. A consumer reading the header cannot learn from it that a NULL argument yields AKERR_NULLPOINTER, or — much more importantly — that a libc failure surfaces the raw errno value as the status with AKERR_IO only as a fallback when errno is 0. That second one is the whole design decision the PR body is announcing, and the header is where a consumer would look for it.

Fix: on each of the six, add the statuses. For example:

 * @throws AKERR_NULLPOINTER If pathname or dest is NULL.
 * @throws AKERR_IO If stat(2) failed and left errno at 0.
 * @throws (errno) The errno stat(2) set, reported directly as the status.
**DEFECT (medium) — no `@throws` on any of the six.** The header carries 293 `@throws` tags across 146 `aksl_` declarations. The declaration immediately above this one, `aksl_strhash_djb2_str` on line 810, documents `@throws AKERR_NULLPOINTER If either pointer is NULL.` These six document none. That leaves the only non-obvious half of the contract undocumented. A consumer reading the header cannot learn from it that a NULL argument yields `AKERR_NULLPOINTER`, or — much more importantly — that a libc failure surfaces the **raw errno value** as the status with `AKERR_IO` only as a fallback when errno is 0. That second one is the whole design decision the PR body is announcing, and the header is where a consumer would look for it. **Fix:** on each of the six, add the statuses. For example: ``` * @throws AKERR_NULLPOINTER If pathname or dest is NULL. * @throws AKERR_IO If stat(2) failed and left errno at 0. * @throws (errno) The errno stat(2) set, reported directly as the status. ```
logikoma marked this conversation as resolved
@@ -811,0 +858,4 @@
* @param[out] dest Filesystem metadata. Required.
* @return NULL on success, an error context otherwise.
*/
akerr_ErrorContext AKERR_NOIGNORE *aksl_fstatvfs(int fd, struct statvfs *dest);
Author
Collaborator

DEFECT (medium) — these six declarations are outside every Doxygen group.

They sit between the /** @} */ on line 811 that closes Paths and hashing and the /* ==== */ /** @name Streams ... @{ banner on line 864-872. Every other one of the 146 aksl_ declarations in this header is inside a @name ... @{ ... @} group with a separator banner — there are 15 such groups. These six are the only ungrouped declarations in 2300 lines, so in the generated documentation they fall outside the module structure the rest of the API is organised by.

Fix: wrap them the way every neighbouring block is wrapped:

/* ====================================================================== */
/** @name File and filesystem metadata
 *
 *  ...rationale...
 *  @{
 */
/* ====================================================================== */

...declarations...

/** @} */

Also, line 862-863 is a double blank line before the Streams banner; every other group boundary in the file uses one.

**DEFECT (medium) — these six declarations are outside every Doxygen group.** They sit between the `/** @} */` on line 811 that closes *Paths and hashing* and the `/* ==== */ /** @name Streams ... @{` banner on line 864-872. Every other one of the 146 `aksl_` declarations in this header is inside a `@name ... @{ ... @}` group with a separator banner — there are 15 such groups. These six are the only ungrouped declarations in 2300 lines, so in the generated documentation they fall outside the module structure the rest of the API is organised by. **Fix:** wrap them the way every neighbouring block is wrapped: ```c /* ====================================================================== */ /** @name File and filesystem metadata * * ...rationale... * @{ */ /* ====================================================================== */ ``` ...declarations... ```c /** @} */ ``` Also, line 862-863 is a double blank line before the Streams banner; every other group boundary in the file uses one.
logikoma marked this conversation as resolved
src/stat.c Outdated
@@ -0,0 +1,88 @@
/* sys/stat.h and sys/statvfs.h metadata wrappers. */
Author
Collaborator

Style — no file-level rationale block.

One line, where the three most recently added wrapper files all open with a paragraph explaining why the wrappers are worth having and what conventions run through the file:

file file-level block
src/stream.c:1-17 17 lines: "the recurring theme, and the reason most of these are worth wrapping at all, is that stdio reports failure through a return value that is easy to mistake for data..."
src/string.c:1-12+ counts the raw call sites in akbasic that motivated it
src/collections.c:1-12+ states what it is not, then the two conventions that hold throughout
src/stat.c:1 /* sys/stat.h and sys/statvfs.h metadata wrappers. */

The per-function blocks added in 01034fc are genuinely good — aksl_fstat's "no path lookup race and remains useful after the file has been renamed or unlinked" is exactly the right kind of note. The file just has no equivalent at the top saying why a caller reaches for this file at all, and where the errno-as-status policy is stated once for all six.

**Style — no file-level rationale block.** One line, where the three most recently added wrapper files all open with a paragraph explaining why the wrappers are worth having and what conventions run through the file: | file | file-level block | |---|---| | `src/stream.c:1-17` | 17 lines: "the recurring theme, and the reason most of these are worth wrapping at all, is that stdio reports failure through a return value that is easy to mistake for data..." | | `src/string.c:1-12+` | counts the raw call sites in akbasic that motivated it | | `src/collections.c:1-12+` | states what it is *not*, then the two conventions that hold throughout | | `src/stat.c:1` | `/* sys/stat.h and sys/statvfs.h metadata wrappers. */` | The per-function blocks added in 01034fc are genuinely good — `aksl_fstat`'s "no path lookup race and remains useful after the file has been renamed or unlinked" is exactly the right kind of note. The file just has no equivalent at the top saying why a caller reaches for this file at all, and where the errno-as-status policy is stated once for all six.
logikoma marked this conversation as resolved
src/stat.c Outdated
@@ -0,0 +1,88 @@
/* sys/stat.h and sys/statvfs.h metadata wrappers. */
#include <akstdlib.h>
#include <errno.h>
#include "aksl_internal.h"
Author
Collaborator

Style — include-what-you-use.

stat, lstat, fstat, fstatat, statvfs and fstatvfs are all declared in <sys/stat.h> / <sys/statvfs.h>, and this file gets both only transitively through <akstdlib.h>. src/stream.c:19-27 re-includes <stdio.h>, <stdlib.h>, <string.h> and <sys/types.h> explicitly even though akstdlib.h already supplies every one of them.

Same file, the grouping differs too: stream.c separates the project header, the system headers, and "aksl_internal.h" with blank lines. Here all four run together.

Fix:

#include <akstdlib.h>

#include <errno.h>
#include <sys/stat.h>
#include <sys/statvfs.h>

#include "aksl_internal.h"
**Style — include-what-you-use.** `stat`, `lstat`, `fstat`, `fstatat`, `statvfs` and `fstatvfs` are all declared in `<sys/stat.h>` / `<sys/statvfs.h>`, and this file gets both only transitively through `<akstdlib.h>`. `src/stream.c:19-27` re-includes `<stdio.h>`, `<stdlib.h>`, `<string.h>` and `<sys/types.h>` explicitly even though `akstdlib.h` already supplies every one of them. Same file, the grouping differs too: `stream.c` separates the project header, the system headers, and `"aksl_internal.h"` with blank lines. Here all four run together. **Fix:** ```c #include <akstdlib.h> #include <errno.h> #include <sys/stat.h> #include <sys/statvfs.h> #include "aksl_internal.h" ```
logikoma marked this conversation as resolved
@@ -0,0 +30,4 @@
FAIL_ZERO_RETURN(e, pathname, AKERR_NULLPOINTER, "pathname=%p, dest=%p", (void *)pathname, (void *)dest);
FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "pathname=%p, dest=%p", (void *)pathname, (void *)dest);
errno = 0;
FAIL_NONZERO_RETURN(e, lstat(pathname, dest), AKSL_ERRNO_OR(AKERR_IO), "pathname=%s", pathname);
Author
Collaborator

DEFECT (high) — this failure branch is never executed by any test in the suite.

Measured, not guessed. -DAKSL_COVERAGE=ON, full 20-test ctest run, gcov -b on src/stat.c:

line function libc-failure branch
18 aksl_stat taken 50%
33 aksl_lstat taken 0%
45 aksl_fstat taken 50%
60 aksl_fstatat taken 50%
75 aksl_statvfs taken 0%
87 aksl_fstatvfs taken 0%

Every branch inside the FAIL body on this line reports never executed.

Line coverage hides this completely — it reads Lines executed:100.00% of 34, because the line is reached by the success path. Branch coverage is Taken at least once: 46.77% of 248.

The functional consequence: AKSL_ERRNO_OR(AKERR_IO) on this line is dead code as far as the suite is concerned. That macro is the one src/aksl_internal.h documents as the fix for a FAIL "whose status is 0, which every downstream DETECT and CATCH reads as success while the context still holds a pool slot: an error that is invisible and leaks at the same time." Half the wrappers in this PR ship that mechanism with it never having run once. Drop the errno = 0 on line 32, or write AKERR_IO bare instead of the macro, and the suite stays green.

Fix (one line, next to the existing aksl_stat ENOENT check at tests/test_stat.c:46):

AKSL_CHECK_STATUS(aksl_lstat("/nonexistent/aksl/stat", &ldest), ENOENT);
**DEFECT (high) — this failure branch is never executed by any test in the suite.** Measured, not guessed. `-DAKSL_COVERAGE=ON`, full 20-test `ctest` run, `gcov -b` on `src/stat.c`: | line | function | libc-failure branch | |---|---|---| | 18 | `aksl_stat` | `taken 50%` | | **33** | **`aksl_lstat`** | **`taken 0%`** | | 45 | `aksl_fstat` | `taken 50%` | | 60 | `aksl_fstatat` | `taken 50%` | | **75** | **`aksl_statvfs`** | **`taken 0%`** | | **87** | **`aksl_fstatvfs`** | **`taken 0%`** | Every branch inside the `FAIL` body on this line reports `never executed`. Line coverage hides this completely — it reads `Lines executed:100.00% of 34`, because the *line* is reached by the success path. Branch coverage is `Taken at least once: 46.77% of 248`. The functional consequence: `AKSL_ERRNO_OR(AKERR_IO)` on this line is dead code as far as the suite is concerned. That macro is the one `src/aksl_internal.h` documents as the fix for a FAIL "whose status is 0, which every downstream DETECT and CATCH reads as success while the context still holds a pool slot: an error that is invisible and leaks at the same time." Half the wrappers in this PR ship that mechanism with it never having run once. Drop the `errno = 0` on line 32, or write `AKERR_IO` bare instead of the macro, and the suite stays green. **Fix (one line, next to the existing `aksl_stat` ENOENT check at `tests/test_stat.c:46`):** ```c AKSL_CHECK_STATUS(aksl_lstat("/nonexistent/aksl/stat", &ldest), ENOENT); ```
logikoma marked this conversation as resolved
@@ -0,0 +42,4 @@
akerr_ErrorContext AKERR_NOIGNORE *aksl_fstat(int fd, struct stat *dest)
{
PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "fd=%d, dest=%p", fd, (void *)dest);
errno = 0; FAIL_NONZERO_RETURN(e, fstat(fd, dest), AKSL_ERRNO_OR(AKERR_IO), "fd=%d", fd); SUCCEED_RETURN(e);
Author
Collaborator

Style — multiple statements per line, and it is not only a style question here.

Lines 44-45 put five statements on two lines. src/stat.c:86-87 in aksl_fstatvfs does the same. The other four wrappers in this file, and every wrapper in src/stream.c, put one statement per line.

Worth separating the cosmetic part from the real part. PREPARE_ERROR, FAIL_ZERO_RETURN, FAIL_NONZERO_RETURN and SUCCEED_RETURN are all multi-statement macros with no wrapping bracesFAIL_ZERO_RETURN expands to a bare if ( __x == 0 ) { FAIL(...); return __err_context; } and SUCCEED_RETURN expands to RELEASE_ERROR(e); return NULL;, which is itself two statements including an if. PREPARE_ERROR expands to a function call plus a declaration.

The code as written is correct — I checked the expansion. But collapsing unbraced control-flow macros onto a shared line is the exact shape that makes the next edit here go wrong silently, and the one-statement-per-line rule exists to stop it. This is also the only place in the file where the visual structure does not match the other five wrappers, which is what makes a reviewer stop.

Fix:

    PREPARE_ERROR(e);
    FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "fd=%d, dest=%p", fd, (void *)dest);
    errno = 0;
    FAIL_NONZERO_RETURN(e, fstat(fd, dest), AKSL_ERRNO_OR(AKERR_IO), "fd=%d", fd);
    SUCCEED_RETURN(e);
**Style — multiple statements per line, and it is not only a style question here.** Lines 44-45 put five statements on two lines. `src/stat.c:86-87` in `aksl_fstatvfs` does the same. The other four wrappers in this file, and every wrapper in `src/stream.c`, put one statement per line. Worth separating the cosmetic part from the real part. `PREPARE_ERROR`, `FAIL_ZERO_RETURN`, `FAIL_NONZERO_RETURN` and `SUCCEED_RETURN` are all **multi-statement macros with no wrapping braces** — `FAIL_ZERO_RETURN` expands to a bare `if ( __x == 0 ) { FAIL(...); return __err_context; }` and `SUCCEED_RETURN` expands to `RELEASE_ERROR(e); return NULL;`, which is itself two statements including an `if`. `PREPARE_ERROR` expands to a function call *plus a declaration*. The code as written is correct — I checked the expansion. But collapsing unbraced control-flow macros onto a shared line is the exact shape that makes the next edit here go wrong silently, and the one-statement-per-line rule exists to stop it. This is also the only place in the file where the visual structure does not match the other five wrappers, which is what makes a reviewer stop. **Fix:** ```c PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "fd=%d, dest=%p", fd, (void *)dest); errno = 0; FAIL_NONZERO_RETURN(e, fstat(fd, dest), AKSL_ERRNO_OR(AKERR_IO), "fd=%d", fd); SUCCEED_RETURN(e); ```
logikoma marked this conversation as resolved
src/stat.c Outdated
@@ -0,0 +57,4 @@
FAIL_ZERO_RETURN(e, pathname, AKERR_NULLPOINTER, "dirfd=%d, pathname=%p, dest=%p", dirfd, (void *)pathname, (void *)dest);
FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "dirfd=%d, pathname=%p, dest=%p", dirfd, (void *)pathname, (void *)dest);
errno = 0;
FAIL_NONZERO_RETURN(e, fstatat(dirfd, pathname, dest, flags), AKSL_ERRNO_OR(AKERR_IO), "dirfd=%d pathname=%s flags=%d", dirfd, pathname, flags);
Author
Collaborator

Style nit — error message formatting diverges from its five siblings.

"dirfd=%d pathname=%s flags=%d" is space-separated. Every other error message in this file uses ", ":

  • line 15, 16: "pathname=%p, dest=%p"
  • line 30, 31: "pathname=%p, dest=%p"
  • line 44: "fd=%d, dest=%p"
  • line 57, 58: "dirfd=%d, pathname=%p, dest=%p" — the same function, two lines up, uses commas
  • line 72, 73: "path=%p, dest=%p"
  • line 86: "fd=%d, dest=%p"

Also flags=%d prints a bit mask in decimal. AT_SYMLINK_NOFOLLOW lands in a log as flags=256, which is a small unkindness to whoever is reading that log after the fact. flags=0x%x costs nothing.

Fix: "dirfd=%d, pathname=%s, flags=0x%x".

**Style nit — error message formatting diverges from its five siblings.** `"dirfd=%d pathname=%s flags=%d"` is space-separated. Every other error message in this file uses `", "`: - line 15, 16: `"pathname=%p, dest=%p"` - line 30, 31: `"pathname=%p, dest=%p"` - line 44: `"fd=%d, dest=%p"` - line 57, 58: `"dirfd=%d, pathname=%p, dest=%p"` — the *same function*, two lines up, uses commas - line 72, 73: `"path=%p, dest=%p"` - line 86: `"fd=%d, dest=%p"` Also `flags=%d` prints a bit mask in decimal. `AT_SYMLINK_NOFOLLOW` lands in a log as `flags=256`, which is a small unkindness to whoever is reading that log after the fact. `flags=0x%x` costs nothing. **Fix:** `"dirfd=%d, pathname=%s, flags=0x%x"`.
logikoma marked this conversation as resolved
@@ -0,0 +72,4 @@
FAIL_ZERO_RETURN(e, path, AKERR_NULLPOINTER, "path=%p, dest=%p", (void *)path, (void *)dest);
FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "path=%p, dest=%p", (void *)path, (void *)dest);
errno = 0;
FAIL_NONZERO_RETURN(e, statvfs(path, dest), AKSL_ERRNO_OR(AKERR_IO), "path=%s", path);
Author
Collaborator

DEFECT (high) — aksl_statvfs's failure branch is never executed.

Third of the three. gcov, full suite: taken 0%, FAIL body never executed. tests/test_stat.c:69 is the only non-NULL call and it is the success path.

Fix, at tests/test_stat.c:70:

AKSL_CHECK_STATUS(aksl_statvfs("/nonexistent/aksl/stat", &vdest), ENOENT);
**DEFECT (high) — `aksl_statvfs`'s failure branch is never executed.** Third of the three. gcov, full suite: `taken 0%`, `FAIL` body `never executed`. `tests/test_stat.c:69` is the only non-NULL call and it is the success path. **Fix, at `tests/test_stat.c:70`:** ```c AKSL_CHECK_STATUS(aksl_statvfs("/nonexistent/aksl/stat", &vdest), ENOENT); ```
logikoma marked this conversation as resolved
@@ -0,0 +84,4 @@
akerr_ErrorContext AKERR_NOIGNORE *aksl_fstatvfs(int fd, struct statvfs *dest)
{
PREPARE_ERROR(e); FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "fd=%d, dest=%p", fd, (void *)dest);
errno = 0; FAIL_NONZERO_RETURN(e, fstatvfs(fd, dest), AKSL_ERRNO_OR(AKERR_IO), "fd=%d", fd); SUCCEED_RETURN(e);
Author
Collaborator

DEFECT (high) — aksl_fstatvfs's failure branch is never executed, and this one is a two-line fix.

Same gcov measurement as src/stat.c:33: the libc-failure branch here is taken 0% across the whole 20-test suite, and every branch in the FAIL body reports never executed.

This one is the most conspicuous of the three, because its sibling already has the test. tests/test_stat.c:29-32 closes the descriptor and then asserts aksl_fstat reports EBADF on it — but the aksl_fstatvfs call three lines earlier (line 27) is only exercised on the success path. The test already has a closed fd sitting right there.

Fix, inserted at tests/test_stat.c:32 beside the existing check:

    /* A descriptor that was just closed must surface libc's EBADF. */
    AKSL_CHECK_STATUS(aksl_fstat(fd, &fd_dest), EBADF);
    AKSL_CHECK_STATUS(aksl_fstatvfs(fd, &vfs_dest), EBADF);

(Separately, lines 86-87 cram five statements onto two lines — see the note on line 45.)

**DEFECT (high) — `aksl_fstatvfs`'s failure branch is never executed, and this one is a two-line fix.** Same gcov measurement as `src/stat.c:33`: the libc-failure branch here is `taken 0%` across the whole 20-test suite, and every branch in the `FAIL` body reports `never executed`. This one is the most conspicuous of the three, because its sibling already has the test. `tests/test_stat.c:29-32` closes the descriptor and then asserts `aksl_fstat` reports `EBADF` on it — but the `aksl_fstatvfs` call three lines earlier (line 27) is only exercised on the success path. The test already has a closed fd sitting right there. **Fix, inserted at `tests/test_stat.c:32` beside the existing check:** ```c /* A descriptor that was just closed must surface libc's EBADF. */ AKSL_CHECK_STATUS(aksl_fstat(fd, &fd_dest), EBADF); AKSL_CHECK_STATUS(aksl_fstatvfs(fd, &vfs_dest), EBADF); ``` (Separately, lines 86-87 cram five statements onto two lines — see the note on line 45.)
logikoma marked this conversation as resolved
@@ -0,0 +58,4 @@
AKSL_CHECK(S_ISLNK(ldest.st_mode));
/* AT_FDCWD makes fstatat resolve this path from the current directory. */
AKSL_CHECK_OK(aksl_fstatat(AT_FDCWD, path, &dest, 0));
Author
Collaborator

DEFECT (medium) — the two things that make fstatat worth wrapping are both untested.

Across the whole file, every aksl_fstatat call passes AT_FDCWD — lines 61, 65, 89, 90. No test ever opens a directory and passes a real directory descriptor. And no test passes a valid non-zero flag: line 61 passes 0, line 65 passes 0x40000000 which is deliberately invalid.

src/stat.c:49-52 states the contract in its own words:

pathname is resolved relative to dirfd unless it is absolute, and flags retain the libc choices such as inspecting a link itself.

Neither clause is under test. Concretely: a aksl_fstatat that ignored dirfd and passed AT_FDCWD unconditionally would pass this suite. So would one that passed 0 for flags regardless of what the caller asked for — which would silently turn every AT_SYMLINK_NOFOLLOW caller into a symlink-following one, and that is a security-relevant behaviour to get wrong quietly.

The file already builds a symlink at line 52, so the second case is nearly free.

Fix:

    /* A real dirfd must resolve a relative name against that directory. */
    dirfd = open("/tmp", O_RDONLY | O_DIRECTORY);
    AKSL_CHECK(dirfd >= 0);
    AKSL_CHECK_OK(aksl_fstatat(dirfd, strrchr(path, '/') + 1, &dest, 0));
    AKSL_CHECK(dest.st_ino == ldest.st_ino);
    AKSL_CHECK(close(dirfd) == 0);

    /* AT_SYMLINK_NOFOLLOW must reach libc unchanged, making this an lstat. */
    AKSL_CHECK_OK(aksl_fstatat(AT_FDCWD, linkpath, &dest, AT_SYMLINK_NOFOLLOW));
    AKSL_CHECK(S_ISLNK(dest.st_mode));

(path comes from aksl_temp_file, which builds it under $TMPDIR or /tmp; pull the directory from the same source rather than hardcoding /tmp.)

**DEFECT (medium) — the two things that make `fstatat` worth wrapping are both untested.** Across the whole file, every `aksl_fstatat` call passes `AT_FDCWD` — lines 61, 65, 89, 90. No test ever opens a directory and passes a real directory descriptor. And no test passes a *valid non-zero* flag: line 61 passes 0, line 65 passes `0x40000000` which is deliberately invalid. `src/stat.c:49-52` states the contract in its own words: > pathname is resolved relative to dirfd unless it is absolute, and flags retain the libc choices such as inspecting a link itself. Neither clause is under test. Concretely: a `aksl_fstatat` that ignored `dirfd` and passed `AT_FDCWD` unconditionally would pass this suite. So would one that passed `0` for `flags` regardless of what the caller asked for — which would silently turn every `AT_SYMLINK_NOFOLLOW` caller into a symlink-following one, and that is a security-relevant behaviour to get wrong quietly. The file already builds a symlink at line 52, so the second case is nearly free. **Fix:** ```c /* A real dirfd must resolve a relative name against that directory. */ dirfd = open("/tmp", O_RDONLY | O_DIRECTORY); AKSL_CHECK(dirfd >= 0); AKSL_CHECK_OK(aksl_fstatat(dirfd, strrchr(path, '/') + 1, &dest, 0)); AKSL_CHECK(dest.st_ino == ldest.st_ino); AKSL_CHECK(close(dirfd) == 0); /* AT_SYMLINK_NOFOLLOW must reach libc unchanged, making this an lstat. */ AKSL_CHECK_OK(aksl_fstatat(AT_FDCWD, linkpath, &dest, AT_SYMLINK_NOFOLLOW)); AKSL_CHECK(S_ISLNK(dest.st_mode)); ``` (`path` comes from `aksl_temp_file`, which builds it under `$TMPDIR` or `/tmp`; pull the directory from the same source rather than hardcoding `/tmp`.)
logikoma marked this conversation as resolved
@@ -0,0 +62,4 @@
/* Invalid flags must replace stale errno with the EINVAL libc reports. */
errno = E2BIG;
AKSL_CHECK_STATUS(aksl_fstatat(AT_FDCWD, path, &dest, 0x40000000), EINVAL);
Author
Collaborator

Nit — magic number.

0x40000000 is "a flag bit libc will reject", and nothing in the file says so. It is currently unallocated on Linux, but the AT_ space is still being handed out — AT_RECURSIVE (0x8000), the AT_STATX_* bits and AT_HANDLE_FID were all added over time. When 0x40000000 is eventually assigned, this test either starts failing for a reason nobody will connect to this line, or worse, keeps passing for a different reason.

Fix: name it and say why.

/*
 * A bit libc has never assigned in the AT_ space. fstatat(2) must reject it
 * with EINVAL rather than ignoring it, which is what proves flags reach libc
 * unmasked. Re-check this constant if AT_ ever grows into 0x40000000.
 */
#define AKSL_TEST_AT_INVALID 0x40000000
**Nit — magic number.** `0x40000000` is "a flag bit libc will reject", and nothing in the file says so. It is currently unallocated on Linux, but the `AT_` space is still being handed out — `AT_RECURSIVE` (0x8000), the `AT_STATX_*` bits and `AT_HANDLE_FID` were all added over time. When 0x40000000 is eventually assigned, this test either starts failing for a reason nobody will connect to this line, or worse, keeps passing for a different reason. **Fix:** name it and say why. ```c /* * A bit libc has never assigned in the AT_ space. fstatat(2) must reject it * with EINVAL rather than ignoring it, which is what proves flags reach libc * unmasked. Re-check this constant if AT_ ever grows into 0x40000000. */ #define AKSL_TEST_AT_INVALID 0x40000000 ```
logikoma marked this conversation as resolved
@@ -0,0 +63,4 @@
/* Invalid flags must replace stale errno with the EINVAL libc reports. */
errno = E2BIG;
AKSL_CHECK_STATUS(aksl_fstatat(AT_FDCWD, path, &dest, 0x40000000), EINVAL);
AKSL_CHECK(errno == EINVAL);
Author
Collaborator

DEFECT (medium) — this asserts on errno after intervening library calls that are allowed to change it.

AKSL_CHECK_STATUS on line 65 does not just evaluate the expression. It calls aksl_take() (tests/aksl_capture.h), which runs four snprintf calls and akerr_release_error() before returning. Only then does line 66 read errno.

C permits a standard library function to set errno to a non-zero value even on a successful call. So this assertion is relying on glibc's snprintf happening not to touch errno for these particular inputs. It passes today. It is not guaranteed by anything, and it will fail on a libc where that is not true, with a failure message that points at fstatat rather than at snprintf.

Two further problems with it:

  1. It is redundant. Line 65 already proved the wrapper reported EINVAL. Line 66 adds no coverage of the wrapper.
  2. It asserts a contract the library does not offer. Nothing in include/akstdlib.h promises anything about errno after a wrapper returns — and it could not, because the NULL-argument paths (src/stat.c:15-16, 30-31, 44, 57-58, 72-73, 86) return before errno = 0 is ever reached, so on those paths errno is whatever stale value the caller already had. Codifying a post-call errno reading for one path out of two is how an accidental contract gets born.

Fix: delete line 66. The errno = E2BIG on line 64 is the valuable part — it proves the wrapper does not report a stale errno — and line 65 already checks it.

Worth stating the other half plainly, since it is the thing this test was reaching for: the errno handling in the library itself is correct. I traced it. FAIL expands to ENSURE_ERROR_READY(e); e->status = __err;, so AKSL_ERRNO_OR is evaluated after akerr_next_error() runs — and akerr_next_error() (deps/libakerror/src/error.c:240-248) is a bare loop over AKERR_ARRAY_ERROR with no library calls in it, so it cannot clobber errno in between. All six wrappers reset errno immediately before their libc call and read it before anything else can run. No defect there.

**DEFECT (medium) — this asserts on `errno` after intervening library calls that are allowed to change it.** `AKSL_CHECK_STATUS` on line 65 does not just evaluate the expression. It calls `aksl_take()` (`tests/aksl_capture.h`), which runs **four `snprintf` calls and `akerr_release_error()`** before returning. Only then does line 66 read `errno`. C permits a standard library function to set `errno` to a non-zero value even on a successful call. So this assertion is relying on glibc's `snprintf` happening not to touch `errno` for these particular inputs. It passes today. It is not guaranteed by anything, and it will fail on a libc where that is not true, with a failure message that points at `fstatat` rather than at `snprintf`. Two further problems with it: 1. **It is redundant.** Line 65 already proved the wrapper reported `EINVAL`. Line 66 adds no coverage of the wrapper. 2. **It asserts a contract the library does not offer.** Nothing in `include/akstdlib.h` promises anything about `errno` after a wrapper returns — and it could not, because the NULL-argument paths (`src/stat.c:15-16`, `30-31`, `44`, `57-58`, `72-73`, `86`) return *before* `errno = 0` is ever reached, so on those paths `errno` is whatever stale value the caller already had. Codifying a post-call `errno` reading for one path out of two is how an accidental contract gets born. **Fix:** delete line 66. The `errno = E2BIG` on line 64 is the valuable part — it proves the wrapper does not report a stale errno — and line 65 already checks it. Worth stating the other half plainly, since it is the thing this test was reaching for: **the errno handling in the library itself is correct.** I traced it. `FAIL` expands to `ENSURE_ERROR_READY(e); e->status = __err;`, so `AKSL_ERRNO_OR` is evaluated *after* `akerr_next_error()` runs — and `akerr_next_error()` (`deps/libakerror/src/error.c:240-248`) is a bare loop over `AKERR_ARRAY_ERROR` with no library calls in it, so it cannot clobber `errno` in between. All six wrappers reset `errno` immediately before their libc call and read it before anything else can run. No defect there.
logikoma marked this conversation as resolved
Collaborator

Beginning review-fix work now. I have read every unresolved inline comment (914-926) and will address the header self-containment/API documentation, source rationale/style, and the missing/stronger stat tests on branch 9.

Beginning review-fix work now. I have read every unresolved inline comment (914-926) and will address the header self-containment/API documentation, source rationale/style, and the missing/stronger stat tests on branch 9.
logikoma added 1 commit 2026-08-03 10:45:56 -04:00
Test file metadata failure paths
All checks were successful
libakstdlib CI Build / coverage (push) Successful in 2m51s
libakstdlib CI Build / cmake_build (push) Successful in 2m59s
libakstdlib CI Build / sanitizers (push) Successful in 3m4s
libakstdlib CI Build / mutation_test (push) Successful in 12m14s
15e9104d9e
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
Collaborator

Implemented Tachikoma's review feedback in 15e9104. The file-metadata API is grouped and documented, the header now exports the fstatat flags it documents, source comments and include boundaries follow the local wrapper style, and fstatat/statvfs/fstatvfs failure and forwarding paths have direct tests. Verified: 20/20 CTests, Doxygen, coverage gate (99.5% lines and 46.6% branches overall; src/stat.c 100% lines/functions), and the targeted 40-mutant stat run (22 killed; 18 errno/success-return macro survivors with no configured threshold).

Implemented Tachikoma's review feedback in 15e9104. The file-metadata API is grouped and documented, the header now exports the fstatat flags it documents, source comments and include boundaries follow the local wrapper style, and fstatat/statvfs/fstatvfs failure and forwarding paths have direct tests. Verified: 20/20 CTests, Doxygen, coverage gate (99.5% lines and 46.6% branches overall; src/stat.c 100% lines/functions), and the targeted 40-mutant stat run (22 killed; 18 errno/success-return macro survivors with no configured threshold).
andrew reviewed 2026-08-03 11:02:43 -04:00
andrew merged commit 2b79aca103 into main 2026-08-03 11:05:45 -04:00
Sign in to join this conversation.