← board

struct stat.st_nlink is hardcoded to 1

Measured

link("/tmp/f", "/tmp/g");
stat("/tmp/g", &st);
st_nlink
gcc 2
pxx 1

lib/crtl/src/sys/stat.c:35 assigns buf->st_nlink = 1; unconditionally. The field is declared in the header, so code reads it and gets a plausible answer that happens to be right only for the common case of an unlinked regular file.

The PAL does not carry the value at all — TPalFileStat has no Nlink — so this is not a wiring slip in crtl, it is a field that was never plumbed.

Why it matters

st_nlink is not decoration:

Wrong in the direction that makes callers do less work than they should, silently.

Fix shape

Add Nlink to TPalFileStat, fill it from statx's stx_nlink in the posix backend (the syscall already returns it — PAL_STATX_BASIC_STATS includes STATX_NLINK, so no extra request mask is needed), report it honestly on ESP, and read it in lib/crtl/src/sys/stat.c instead of assigning 1.

Worth checking the other struct stat fields in the same pass for the same shape — this one was found by accident, not by looking, and a hardcoded field is invisible until something depends on it. st_ino, st_dev, st_blocks, st_blksize and the uid/gid pair are the candidates.

Gate

link() making the target's st_nlink 2 and unlink bringing it back to 1, diffed against gcc; a directory's st_nlink matching gcc's for a directory with known subdirectories. Cross-target, since lib/crtl builds for every target.

Resolved 2026-08-02 (commit c19f4d9b1) — and it was four fields, not one

The audit this ticket asked for ("worth checking the other struct stat fields in the same pass") found three more, all hardcoded in fill():

field was now
st_nlink 1 real
st_uid / st_gid 0 real
st_rdev 0 real
st_atime / st_ctime both assigned mtime real

The timestamps are the one that would have been missed by fixing only what this ticket named. All three of atime/mtime/ctime carried the same value, so any code comparing access against modification time saw them equal — always, silently. st_dev, st_ino, st_size, st_blocks and st_blksize were already real.

No new syscall work was needed. statx already returns every one of these (PAL_STATX_BASIC_STATS covers them); TPalFileStat simply never carried them and fill() substituted constants. Widened the PAL record, the C-side struct __pxx_statbuf and its Pascal mirror TPxxStatBufappending in each case, because that layout is an ABI shared between pxxcio.pas and lib/crtl/src/sys/stat.c and the two must stay in step; appending leaves every existing offset untouched.

The ESP backend reports the new fields as its ClearPalFileStat defaults, which is honest: it has no stat syscall at all and already returns PAL_ERR_UNSUPPORTED.

Verified through consequences, not presence

test/cstat_fields.c asserts what the fields MEAN: nlink rises to 2 when a hard link is created and falls back when it is removed, both names share an inode, a directory's nlink counts its subdirectory (3, not 2), the uid matches geteuid() and is non-zero, and all three timestamps are populated. Identical to gcc on x86-64, i386, aarch64 and arm32.

A test asserting merely "st_nlink == 1 for a new file" would have passed against the bug, which is why every assertion here moves a value.

Noted, not worked around

getuid is not declared in crtl (geteuid is). The test uses geteuid and says so rather than quietly picking the one that happens to exist — it belongs with the other unistd.h gaps.

Log