schfs: a format version policy, and refusing is not falling back

uber_valid() answered one question -- valid or not -- and a version mismatch
returned the same as a failed checksum. Those two states need opposite handling:

  A damaged uberblock means STEP OVER IT. That is what the 128-slot ring is for,
  and the older root is a consistent filesystem. This is crash recovery.

  A sound uberblock from a newer format means STOP. Falling back mounts a STALE
  filesystem: every commit made since the volume was upgraded is discarded, the
  mount reports success, and nobody finds out.

Demonstrated rather than asserted. A forged image with two uberblocks --
(version 1, generation 1, root 129) and (version 2, generation 2, root 130) --
made the old code mount root 129 and exit 0, silently rolling the filesystem
back to before the upgrade.

uber_check() returns BAD / OK / RO / TOO_NEW, and asks its questions in order:
is it ours (magic), is it intact (crc), can we read it (version, features), does
it make sense (root in range). Compatibility is asked before sanity on purpose --
the fields a sanity check reads are only meaningfully placed if the format is one
we know.

Plus feature flags in three classes, which is what stops a version number being a
blunt instrument: compat (ignore unknown bits), ro_compat (mount read-only),
incompat (refuse). The test for which class is one question -- what happens if a
driver that does not know this feature writes to the volume? Nothing -> compat.
Something goes stale -> ro_compat. Something is unreadable -> incompat.

They occupy what was reserved0, so every later field keeps its offset and
existing images -- which have zeros there -- read unchanged. schfs_vol_commit
memsets the uberblock before filling it in, which is why v1 volumes already say
"no features" without having been written by code that knew the field existed.
That trick is available exactly once. Four _Static_asserts now pin the layout.

Verified on forged images: version bump refuses, incompat refuses, blocksize
mismatch refuses, ro_compat mounts the NEWEST root read-only rather than falling
back.

Also here, because they are the same file: SchFS's transaction owner is a pid
alongside the pointer, since a bare struct proc * does not identify a process --
ptable slots are recycled, and an exited owner's pointer compares equal to
whichever unrelated process lands in that slot next, at which point klock() skips
the lock entirely and two callers share one volume's scratch pages. And
schfs_lockinfo(), so /proc/fsstat can report a wedged volume instead of leaving
every path asleep in acquiresleep with no way to ask why.

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:13:59 +02:00
commit 73acc40dbb
6 changed files with 250 additions and 31 deletions

View file

@ -136,7 +136,52 @@ struct schfs_item {
// the boot block and the uberblock ring.
#define SCHFS_FIRST_BLOCK (SCHFS_UBER_START + SCHFS_UBER_COUNT)
// ---------------------------------------------------------------------------
// The on-disk format version, and what a bump means
//
// The rule, so that "can this driver read this volume" has an answer that does
// not depend on reading the release notes:
//
// * SCHFS_VERSION changes only when the *frame* changes -- the uberblock, the
// node header, the key, the things every reader must understand before it
// can parse anything at all. A driver refuses a version above its own,
// loudly, and says so; it does not guess and it does not fall back to an
// older uberblock, because an older uberblock is a stale filesystem.
//
// * Everything else is a FEATURE FLAG, and this is what keeps the version
// number from being a blunt instrument. Adding a record type, an index
// kind or an optional field does not break a reader that ignores it, so it
// must not cost every older reader the volume.
//
// Three classes, the arrangement ext4 and btrfs both arrived at because it is
// the one that works:
//
// compat Unknown bits may be ignored. Mount read-write regardless.
// ro_compat Unknown bits mean the volume may be MOUNTED READ-ONLY. The
// writer would maintain a structure this driver does not know
// about, so writing would silently invalidate it.
// incompat Unknown bits mean REFUSE. The volume cannot be parsed correctly
// without understanding them.
//
// Choosing the class is the whole design decision, and the test is simple:
// what happens if a driver that does not know this feature writes to the
// volume? Nothing -> compat. Something on disk goes stale -> ro_compat.
// Something is unreadable -> incompat.
//
// All three words are zero on every volume written so far: schfs_vol_commit
// memsets the uberblock before filling it in, so v1 images already say "no
// features" without having been written by code that knew the field existed.
// ---------------------------------------------------------------------------
#define SCHFS_VERSION 1
// No features are defined yet. The masks exist so that the *checks* exist:
// adding a flag later is one bit here, and every driver already refuses
// correctly for the bits it does not have.
#define SCHFS_FEAT_COMPAT_SUPP 0u
#define SCHFS_FEAT_RO_COMPAT_SUPP 0u
#define SCHFS_FEAT_INCOMPAT_SUPP 0u
#define SCHFS_LABEL_MAX 32
// A retained tree root.
@ -180,14 +225,36 @@ struct schfs_uberblock {
// tree at mount rather than stored, so this is what the volume believed at
// commit time — useful for df before anything is mounted, never trusted.
uint64_t free_blocks;
uint64_t reserved0;
// Feature flags. See the block comment above SCHFS_VERSION for what the
// three classes mean and how to choose between them.
//
// These occupy what was one `uint64_t reserved0`, so the offset of every
// field after them is unchanged and a v1 image -- which has zeros here --
// reads identically. That is the only reason this could be added without a
// version bump, and it is the last time that trick is available.
uint32_t feature_incompat;
uint32_t feature_ro_compat;
uint64_t next_objectid;
uint64_t root_objectid; // normally SCHFS_ROOT_OBJECTID
uint8_t uuid[16];
char label[SCHFS_LABEL_MAX];
uint8_t pad[SCHFS_BLKSIZE - 136];
uint32_t feature_compat;
uint8_t pad[SCHFS_BLKSIZE - 140];
};
// The uberblock is one block, and every field above is at a fixed offset in it.
// Both are on-disk facts rather than C conveniences, so the compiler checks
// them: a padding change or a reordered field is a format change that would
// otherwise be found by a volume that no longer mounts.
_Static_assert(sizeof(struct schfs_uberblock) == SCHFS_BLKSIZE,
"uberblock must be exactly one block");
_Static_assert(__builtin_offsetof(struct schfs_uberblock, version) == 8,
"uberblock version moved -- older drivers read this offset");
_Static_assert(__builtin_offsetof(struct schfs_uberblock, feature_incompat) == 64,
"feature flags moved -- they must stay where reserved0 was");
_Static_assert(__builtin_offsetof(struct schfs_uberblock, next_objectid) == 72,
"fields after the feature flags must not shift");
// ---------------------------------------------------------------------------
// Records
// ---------------------------------------------------------------------------