From 73acc40dbbc304b1d9467e3e814994431827a280 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Sat, 12 Sep 2026 17:13:59 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2 --- include/defs.h | 13 ++++- include/errno.h | 3 ++ include/schfs.h | 71 ++++++++++++++++++++++++- kernel/fs/schfs/kmount.c | 76 +++++++++++++++++++++++---- kernel/fs/schfs/vol.c | 110 +++++++++++++++++++++++++++++++++------ tools/schfs_fsck.c | 8 +++ 6 files changed, 250 insertions(+), 31 deletions(-) diff --git a/include/defs.h b/include/defs.h index acb6406..b985e0c 100644 --- a/include/defs.h +++ b/include/defs.h @@ -181,6 +181,7 @@ int schfs_kreaddir_from_dev(uint dev, uint64 dir, uint64 start_hash, unsigned, uint64, void *), void *arg); uint64 schfs_kroot(uint dev); +void schfs_lockinfo(int slot, int *locked, int *owner_pid, int *depth); int schfs_kinfo(char *buf, int cap); // Write side. Each is one transaction and is durable by the time it returns, @@ -366,7 +367,7 @@ int pipewrite(struct pipe*, char*, int); // net/unix.c struct socket; void sockinit(void); -struct socket* socknew(int domain, int type); +struct socket* socknew(int domain, int type, int proto); void sockclose(struct socket*); void sockdup(struct socket*); int sockread(struct socket*, char*, int); @@ -389,13 +390,19 @@ int socksendto(struct socket*, char *buf, int n, int sockbind_in(struct socket*, uint addr, ushort port); int sockconnect_in(struct socket*, uint addr, ushort port); int sock_domain(struct socket*); +// Socket options. The syscall layer does the user-memory marshalling and these +// do the work, because struct socket's fields live in kernel/net/unix.c and the +// syscall layer deliberately cannot see them. +int sockopt_set(struct socket*, int level, int name, int val); +int sockopt_get(struct socket*, int level, int name, int *out); // net/inet.c -- AF_INET on lwIP's raw API. Called only from net/unix.c, which // is where the per-domain dispatch lives. void inet_init(void); -void* inet_new(int type); +void* inet_new(int type, int proto); int inet_bind(void *h, uint addr, ushort port); int inet_listen(void *h, int backlog); +int inet_setopt(void *h, int level, int name, int val); void* inet_accept(void *h); int inet_connect(void *h, uint addr, ushort port); int inet_read(void *h, char *buf, int n); @@ -464,6 +471,7 @@ void* memset(void*, int, uint64); char* safestrcpy(char*, const char*, int); int strlen(const char*); int strnlen(const char*, int); +int strcmp(const char*, const char*); int strncmp(const char*, const char*, uint64); char* strncpy(char*, const char*, int); @@ -517,6 +525,7 @@ int diskwrite(struct inode*, uint64, char*, int); // net_is_up reports whether an address has been obtained. void netinit(void); int net_is_up(void); +int net_is_ready(void); int net_info(char *buf, int cap); // net/lwip/ -- the port layer and the Ethernet interfaces, called only from diff --git a/include/errno.h b/include/errno.h index f29bd5c..a29d184 100644 --- a/include/errno.h +++ b/include/errno.h @@ -57,6 +57,9 @@ #define EISCONN 106 // Transport endpoint is already connected #define ENODATA 61 // No data available -- no such attribute #define ENOBUFS 105 // No buffer space available +#define ENOTSUP 95 // Operation not supported +#define ENETDOWN 100 // Network is down +#define ENETUNREACH 101 // Network is unreachable #define ENOTCONN 107 // Transport endpoint is not connected #define ECONNREFUSED 111 // Connection refused diff --git a/include/schfs.h b/include/schfs.h index 452bc20..25b5928 100644 --- a/include/schfs.h +++ b/include/schfs.h @@ -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 // --------------------------------------------------------------------------- diff --git a/kernel/fs/schfs/kmount.c b/kernel/fs/schfs/kmount.c index 81c683b..fd83518 100644 --- a/kernel/fs/schfs/kmount.c +++ b/kernel/fs/schfs/kmount.c @@ -106,7 +106,16 @@ struct schfs_mount { struct sleeplock lock; // The explicit transaction bracket, per volume for the same reason. + // + // The pid is carried alongside the pointer, and both must match, because a + // `struct proc *` alone does not identify a process: ptable slots are + // recycled, so an exited owner's pointer compares equal to whichever + // unrelated process next lands in that slot. tx_mine() would then be true for + // a process that holds nothing, klock() would skip the lock entirely, and two + // callers would share one volume's scratch pages. A recycled slot always + // carries a new pid, so the pair is unambiguous. struct proc *tx_owner; + int tx_owner_pid; int tx_depth; int tx_dirty; }; @@ -124,13 +133,27 @@ static int ksnap_home[SCHFS_SNAP_SLOTS]; // which volume slot static char *ksnapcurblk[SCHFS_SNAP_SLOTS]; static char *ksnapdatablk[SCHFS_SNAP_SLOTS]; -// Seconds, for inode timestamps. The kernel has no wall clock wired up, so this -// matches what fs.c does for the v1 filesystem rather than inventing a second, -// differently-wrong answer. +// Seconds since the epoch, for inode timestamps. +// +// This used to return uptime(), with a comment saying the kernel had no wall +// clock. It has had one since time_init() started reading the RTC at boot, and +// the stale comment outlived the condition it described -- which is how every +// file created after boot ended up stamped 1970. +// +// Two errors, not one, and the second is the less obvious: uptime() counts +// 10 ms *ticks*, and they were being used as *seconds*. A node created 60 ticks +// (0.6 s) into boot therefore read back as 00:01:00, so the timestamps were not +// merely offset, they ran a hundred times too fast. +// +// wallclock() is the one source (kernel/core/time.c), so this cannot disagree +// with gettimeofday about what time it is -- which was the whole reason that +// function exists. static uint64_t know(void) { - return (uint64_t)uptime(); + uint64 sec; + wallclock(&sec, 0); + return (uint64_t)sec; } // Explicit transaction brackets. @@ -220,7 +243,9 @@ writable_for(uint dev) static int tx_mine(struct schfs_mount *m) { - return m->tx_owner != 0 && m->tx_owner == myproc(); + struct proc *p = myproc(); + return m->tx_owner != 0 && p != 0 && + m->tx_owner == p && m->tx_owner_pid == p->pid; } // Take a volume's lock unless this process already holds it through a bracket. @@ -243,6 +268,26 @@ kunlock(uint dev) releasesleep(&m->lock); } +// The bracket's state, for /proc/fsstat. A wedged filesystem looks exactly like +// a slow one from userland -- every path hangs in acquiresleep with no way to +// tell whether anything owns the lock. This says so. +// +// Read without the lock on purpose: the one time it matters is when the lock +// cannot be taken, and a reporting path that blocks on the thing it is +// reporting on is useless. +void +schfs_lockinfo(int slot, int *locked, int *owner_pid, int *depth) +{ + if (slot < 0 || slot >= SCHFS_VOL_SLOTS) { + *locked = *owner_pid = *depth = -1; + return; + } + struct schfs_mount *m = &kmnt[slot]; + *locked = m->mounted ? m->lock.locked : -1; + *owner_pid = m->tx_owner_pid; + *depth = m->tx_depth; +} + void schfs_ktx_open(uint dev) { @@ -253,9 +298,10 @@ schfs_ktx_open(uint dev) return; } acquiresleep(&m->lock); - m->tx_owner = myproc(); - m->tx_depth = 1; - m->tx_dirty = 0; + m->tx_owner = myproc(); + m->tx_owner_pid = myproc() ? myproc()->pid : 0; + m->tx_depth = 1; + m->tx_dirty = 0; } int @@ -271,8 +317,9 @@ schfs_ktx_close(uint dev) if (m->tx_dirty) r = schfs_vol_commit(&m->vol); - m->tx_dirty = 0; - m->tx_owner = 0; + m->tx_dirty = 0; + m->tx_owner = 0; + m->tx_owner_pid = 0; releasesleep(&m->lock); return r; } @@ -461,6 +508,15 @@ kmount_slot(int slot, uint bdev) cprintf("schfs: no memory for the allocation map of device %d\n", bdev); return -ENOMEM; } + if (r == -3) { + // Refused on purpose, and the wording matters: the volume is fine, this + // driver is the thing that is out of date. Older uberblocks may well be + // readable, and mounting one would quietly roll the filesystem back to + // before the upgrade -- so it is not offered. + cprintf("schfs: device %d was written by a newer SchFS than this one " + "understands; refusing rather than mounting a stale root\n", bdev); + return -ENOTSUP; + } if (r != 0) { cprintf("schfs: device %d has a SchFS uberblock but an unreadable tree\n", bdev); diff --git a/kernel/fs/schfs/vol.c b/kernel/fs/schfs/vol.c index e119727..cba4c9f 100644 --- a/kernel/fs/schfs/vol.c +++ b/kernel/fs/schfs/vol.c @@ -34,24 +34,66 @@ uber_crc(struct schfs_uberblock *u) return c; } -static int -uber_valid(struct schfs_uberblock *u) +// What this uberblock is, which is not the same question as whether it is good. +// +// This used to be one predicate returning "valid" or not, and a version +// mismatch answered the same as a failed checksum. That conflates two states +// the caller has to treat in opposite ways: +// +// A damaged uberblock means TRY THE NEXT ONE. That is what the ring is for, +// and falling back to an older generation is exactly right -- it is crash +// recovery, and the older root is a consistent filesystem. +// +// An uberblock this driver is too old to understand means STOP. Falling back +// mounts a *stale* filesystem: every commit made since the volume was +// upgraded is silently discarded, and the mount succeeds, so nobody finds +// out. That is data loss reported as success, which is the worst outcome +// available here. +// +// The order of the checks is the order of the questions. 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. +enum uber_state { + UBER_BAD = 0, // not SchFS, or damaged -- keep looking + UBER_OK, // ours, and fully usable + UBER_RO, // ours, sound, but carries ro_compat bits we lack + UBER_TOO_NEW, // ours, sound, and beyond this driver -- stop the scan +}; + +static enum uber_state +uber_check(struct schfs_uberblock *u) { if (u->magic != SCHFS_MAGIC) - return 0; - if (u->version != SCHFS_VERSION) - return 0; - // A volume made with a different block size would otherwise be read with - // every offset wrong, which looks exactly like corruption. - if (u->blocksize != SCHFS_BLKSIZE) - return 0; + return UBER_BAD; if (uber_crc(u) != u->crc) - return 0; + return UBER_BAD; + + // From here the block is certainly ours and certainly landed intact, so + // anything wrong with it is a statement about the *format*, not the media. + if (u->version > SCHFS_VERSION) + return UBER_TOO_NEW; + // A different block size is not damage either: every offset would be read + // wrong, which is precisely what an incompatible format means. + if (u->blocksize != SCHFS_BLKSIZE) + return UBER_TOO_NEW; + if (u->feature_incompat & ~SCHFS_FEAT_INCOMPAT_SUPP) + return UBER_TOO_NEW; + // Self-consistency: a root inside the ring or past the end of the device is // a corrupt uberblock that happens to checksum, not a usable one. if (u->root < SCHFS_FIRST_BLOCK || u->root >= u->total_blocks) - return 0; - return 1; + return UBER_BAD; + + // Read-only is a real mount, so it is decided last -- after everything that + // would have refused outright. + if (u->feature_ro_compat & ~SCHFS_FEAT_RO_COMPAT_SUPP) + return UBER_RO; + + // feature_compat is deliberately not consulted. Unknown bits there are + // unknown on purpose; that is what the class means. + return UBER_OK; } static void @@ -89,6 +131,8 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev, int seen = 0; // valid uberblocks found, at any generation uint64_t ceiling = ~0ULL; // consider only generations below this int spaced = 0; // the allocation map is built and owned + uint64_t too_new = 0; // newest generation this driver cannot read + int ro_forced = 0; // the adopted root carries ro_compat bits v->dev = *dev; v->ctx = *x; @@ -116,8 +160,19 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev, io_errors++; // count each slot once, on the first sweep continue; // an unreadable slot is not fatal: try the rest } - if (!uber_valid(u)) - continue; + { + enum uber_state st = uber_check(u); + if (st == UBER_TOO_NEW) { + // Recorded, not mounted, and not skipped past. Counted on + // the first sweep only, like io_errors: later sweeps walk + // the same slots with a lower ceiling. + if (ceiling == ~0ULL && u->generation > too_new) + too_new = u->generation; + continue; + } + if (st == UBER_BAD) + continue; + } if (u->generation >= ceiling) continue; // already tried, and rejected if (best_slot < 0 || u->generation > best_gen) { @@ -130,6 +185,15 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev, if (best_slot < 0) break; // nothing older left to try + + // Refuse rather than mount something older than a root we could not + // read. The candidate below is a perfectly good filesystem; it is just + // not the *current* one, and mounting it would discard every commit + // made since the volume was written by a newer driver -- silently, and + // with the mount reporting success. + if (too_new > best_gen) + return -3; + ceiling = best_gen; // next round considers strictly older ones // Re-read the winner rather than keeping a copy of it. @@ -140,8 +204,14 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev, // very first boot. Re-reading costs one block read per candidate. if (v->dev.bread(v->dev.ctx, SCHFS_UBER_START + best_slot, u) != 0) goto next; - if (!uber_valid(u)) - goto next; /* changed under us, or a flaky device */ + { + enum uber_state st = uber_check(u); + if (st == UBER_TOO_NEW) + return -3; /* rewritten under us by a newer driver */ + if (st == UBER_BAD) + goto next; /* changed under us, or a flaky device */ + ro_forced = (st == UBER_RO); + } vol_from_uber(v, u, best_slot); v->readonly = 1; // until an allocation map exists, below @@ -178,7 +248,11 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev, if (schfs_space_scan(v) != 0) goto next; v->free_blocks = v->space.free_blocks; - v->readonly = 0; + // A volume carrying ro_compat bits we do not implement stays + // read-only even though the map is now good: writing would leave + // whatever structure those bits describe stale, and stale is worse + // than absent because nothing would notice. + v->readonly = ro_forced; } // Which attributes are indexed, so attr.c can answer without a lookup // on every write. A volume with no indexes -- every volume until @@ -194,6 +268,8 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev, // Nothing usable. Distinguish "not a SchFS volume" from "a SchFS volume // that cannot be read", because the first is an ordinary answer to a probe // and the second is a damaged disk somebody needs to hear about. + if (too_new) + return -3; if (io_errors == SCHFS_UBER_COUNT) return -2; return seen ? -2 : -1; diff --git a/tools/schfs_fsck.c b/tools/schfs_fsck.c index b617279..2f1a671 100644 --- a/tools/schfs_fsck.c +++ b/tools/schfs_fsck.c @@ -791,6 +791,14 @@ main(int argc, char **argv) "volume, or never successfully written\n", imgpath); return 1; } + if (r == -3) { + fprintf(stderr, "schfs_fsck: %s: written by a newer SchFS than this " + "tool understands (this tool: version %d)\n", + imgpath, SCHFS_VERSION); + fprintf(stderr, "schfs_fsck: refusing -- older uberblocks may be " + "readable, but they are a stale filesystem\n"); + return 1; + } if (r != 0) { fprintf(stderr, "schfs_fsck: %s: newest uberblock names an unreadable " "tree root\n", imgpath);