schfs: records at arbitrary offsets are packed, and the compiler is told

A leaf's value area holds variable-length records end to end, so a record begins
wherever the one before it finished -- a multiple of 8 only by luck. The code
reads them by casting a pointer into the block, which tells the compiler they
are aligned when they are not.

That works on x86, which is why it has never shown as a bug, and it is undefined
in C. A compiler entitled to assume 8-byte alignment may emit an instruction
that requires it, and the failure would arrive at a different optimisation level
or on a different architecture looking like filesystem corruption rather than
like codegen. UndefinedBehaviorSanitizer reported 31 of them across obj.c,
objw.c, attr.c and space.c.

Seven types now carry SCHFS_PACKED: dirent_rec, extent, extref, index_rec,
attr_rec, snap and inode. Two of those -- attr_rec and inode -- were never
reported, because schfs_objtest does not reach an unaligned instance of either.
They were latent rather than absent, and casting into a leaf is the same lie
whether or not a test has caught it yet.

NO ON-DISK LAYOUT CHANGED, and that was measured before the annotation was
applied rather than assumed: every one of these already carries explicit `pad`
fields and therefore has no implicit padding, so sizes and member offsets are
identical with and without it. Seven _Static_asserts now pin the sizes, because
a record that silently grows misreads every existing volume from that point on
and looks like corruption rather than like a struct that changed.

schfs_node_hdr, schfs_item, schfs_kptr and schfs_uberblock are deliberately left
alone. The node header is exactly 32 bytes, so the items and key pointers after
it are already 8-aligned, and annotating them would cost byte-wise access on the
tree's hottest paths to fix a problem they do not have.

Verified by reading an image written before the change: schfs_fsck --check
passes on structure, checksums, key ordering, data blocks and derived free
space, and the kernel mounts its own root unchanged.

Also here: mkschfs passed a null pointer to qsort for an empty directory. Every
implementation ignores it when the count is zero; the standard does not say they
have to.

make schfs-check now runs with no sanitizer output at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-12 17:56:36 +02:00
commit 7c8cf51941
5 changed files with 123 additions and 41 deletions

View file

@ -164,25 +164,34 @@ known reasons stops being read, and then it is red for unknown reasons too.
asserts the balance — 520 pages taken, 520 given back — so the next leak
fails the suite instead of being absorbed by it.
- [ ] **31 UndefinedBehaviorSanitizer errors**, all one root cause: records are
read straight out of a tree leaf by casting a pointer into the block, and
a record's offset within a leaf is not a multiple of 8. Sites:
`obj.c` (9), `objw.c` (11), `attr.c` (7), `space.c` (5), across
`schfs_dirent_rec`, `schfs_extent`, `schfs_extref`, `schfs_index_rec` and
`schfs_snap`.
- [x] ~~**31 UndefinedBehaviorSanitizer errors.**~~ Fixed, plus two the test
never reached. One root cause: a leaf's value area holds variable-length
records packed end to end, so a record begins wherever the last one ended
— a multiple of 8 only by luck — and the code read them by casting a
pointer into the block, which tells the compiler they are aligned when
they are not.
**This is a real hazard, not sanitizer pedantry.** Unaligned access
happens to work on x86, which is why it has never shown as a bug — but it
is undefined in C, and a compiler that knows a pointer is 8-aligned is
entitled to emit an instruction that requires it. The failure would appear
under a different optimisation level or on a different architecture, and
would look like filesystem corruption rather than a codegen problem.
The seven record types that live at arbitrary offsets now carry
`SCHFS_PACKED`. **No on-disk layout changed**, and that was measured
before it was applied rather than assumed: every one already had explicit
`pad` fields, so sizes and member offsets are identical with and without
the annotation. Seven `_Static_assert`s now pin those sizes, because a
record that silently grows misreads every existing volume from that point
on and looks like corruption rather than like a struct that changed.
The fix that keeps the format unchanged is to stop casting: copy the bytes
into an aligned local with `memmove`, or declare the records packed so the
compiler stops assuming. Padding the records to 8 bytes would also work
and costs on-disk space — and is now a **format change**, which is what
the version policy above is for.
`schfs_node_hdr`, `schfs_item`, `schfs_kptr` and `schfs_uberblock` are
deliberately left alone — the node header is exactly 32 bytes, so
everything after it is already 8-aligned, and annotating them would cost
byte-wise access on the tree's hottest paths to fix a problem they do not
have.
Verified by reading an image written *before* the change: full
`schfs_fsck --check` passes on structure, checksums, key ordering, data
blocks and derived free space.
Also caught on the way: `mkschfs` passed a null pointer to `qsort` for an
empty directory. Every implementation ignores it when the count is zero;
the standard does not say they have to.
**The identity item:**