From 3919f1446ec826e1350ef6a34460419fa92dddb0 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Mon, 10 Aug 2026 21:10:59 +0200 Subject: [PATCH] schfs: mount a snapshot by name, read-only `mount -t schfs @golden /mnt` mounts a snapshot. The source argument finally means something: sys_mount ignored it outright, because there was one volume and nothing to choose between. `@name` rather than a path because a snapshot is not a device -- there is no node in /dev for it and there should not be, since it lives inside the volume already mounted. A second volume rather than a parameter threaded through all 23 schfs_k* entry points. Only the read side can ever address it, so the write path keeps talking to the live volume and needed no change; the four read entry points grew _dev variants and pick the volume from the device the inode already carries. One fs_ops table serves both, because every entry takes an inode and dispatches on it -- a second table would be the same pointers under a different name. It needs its own device number: a snapshot's object ids are the *same numbers* as the live tree's, so one device would make the two views alias in the inode cache and hand a reader whichever was cached first. Three things this turned up, none of them new but all of them reachable for the first time: - sys_mount refused with `dev == SCHFS_DEV`, meaning "do not mount SchFS onto SchFS". A snapshot's mount point is necessarily on the live root, so that refused the entire feature. The real condition is mounting a device onto *itself*, which is `dev == fsdev`. - createat allocates an inode and then panics if dirlink fails -- reasonable about a writable filesystem, a crash on a read-only one. The refusal now happens before anything is allocated. Unlink and rename already handled a refusal properly. - a refused create reported ENOENT, so writing to a snapshot said "No such file or directory" -- the sentence a shell prints for every create failure. It reports EROFS now. Verified in the running system against an image built with --snapshot golden: the mount succeeds, ls lists the snapshot's tree, /mnt2/etc/motd reads back through it, and a write is refused with "Read-only file system". One at a time, and no umount path for it yet: mount_del_by_root removes the mount table entry, but ksnap_unmount is only called on the failure paths. schfs-check passes; bootcheck 108 [ok]. Co-Authored-By: Claude Opus 5 --- include/defs.h | 20 +++++ include/file.h | 7 ++ kernel/fs/fs.c | 15 ++++ kernel/fs/schfs/kmount.c | 186 +++++++++++++++++++++++++++++++++++++++ kernel/fs/schfs/vfs.c | 42 +++++++-- kernel/sys/sys_fs.c | 93 +++++++++++++++++--- 6 files changed, 345 insertions(+), 18 deletions(-) diff --git a/include/defs.h b/include/defs.h index cff4c08..880a741 100644 --- a/include/defs.h +++ b/include/defs.h @@ -66,6 +66,9 @@ int getdents(struct inode*, char *out, int cap, uint64 *poff); // Mount table: a substitution applied during path resolution. Nothing on disk // records a mount, so unmounting is forgetting. void mountinit(void); +// 1 if the filesystem on this device refuses writes -- a mounted snapshot. +// Ask before mutating, not after: the create path panics on a failed dirlink. +int fs_readonly(uint dev); int mount_add(uint dev, uint64 inum, uint fsdev, uint64 root); int mount_del_by_root(uint fsdev, uint64 root); struct inode* ialloc(uint, short); @@ -173,6 +176,23 @@ int schfs_kdirdel(uint64 dir, const char *name, unsigned len); int schfs_kfree(uint64 ino); uint64 schfs_kroot(void); +// A snapshot mounted read-only: the same block device with an older tree root. +// The read path takes a device number so the VFS can address either view; there +// is no write path, because a snapshot that could be written to would not be +// one. See kernel/fs/schfs/kmount.c. +int schfs_ksnap_mount(const char *name, unsigned namelen); +void schfs_ksnap_unmount(void); +uint64 schfs_ksnap_root(void); +long schfs_kread_dev(uint dev, uint64 ino, uint64 off, void *buf, + unsigned n); +int schfs_kstat_dev(uint dev, uint64 ino, struct schfs_inode *out); +uint64 schfs_klookup_at_dev(uint dev, uint64 dir, const char *name, + unsigned len); +int schfs_kreaddir_dev(uint dev, uint64 dir, + int (*fn)(uint64, unsigned short, const char *, + unsigned, void *), + void *arg); + // ramdisk.c uint uptime(void); diff --git a/include/file.h b/include/file.h index 4f4f17e..5183ccb 100644 --- a/include/file.h +++ b/include/file.h @@ -102,6 +102,13 @@ struct inode { // Device number that means "the synthetic /proc". Like SCHFS_DEV, it is // deliberately nowhere near a real disk's 0 or 1. +// A mounted snapshot. Same block device, a different tree root, read-only. +// It needs its own device number because an inode is identified by (dev, inum) +// and a snapshot's object ids are the *same numbers* as the live tree's -- one +// number would make the two views alias in the inode cache and hand a reader +// whichever was cached first. +#define SCHFS_SNAP_DEV 66 + #define PROCFS_DEV 65 // table mapping major device number to diff --git a/kernel/fs/fs.c b/kernel/fs/fs.c index a41e1a8..7e95640 100644 --- a/kernel/fs/fs.c +++ b/kernel/fs/fs.c @@ -275,10 +275,25 @@ extern const struct fs_ops schfs_ops; // Defined in fs/procfs.c -- the synthetic /proc. extern const struct fs_ops procfs_ops; +// Is this filesystem read-only? +// +// A mounted snapshot is. Callers must ask *before* they start mutating: the +// create path allocates an inode and then panics if the dirlink fails, which is +// a reasonable assumption about a writable filesystem and a crash on a +// read-only one. +int +fs_readonly(uint dev) +{ + return dev == SCHFS_SNAP_DEV; +} + static const struct fs_ops * ops_for(uint dev) { if (dev == SCHFS_DEV) return &schfs_ops; + // A mounted snapshot is the same filesystem seen through an older root, so it + // is the same operations; they dispatch on the device themselves. + if (dev == SCHFS_SNAP_DEV) return &schfs_ops; if (dev == PROCFS_DEV) return &procfs_ops; return &v1_ops; } diff --git a/kernel/fs/schfs/kmount.c b/kernel/fs/schfs/kmount.c index 578a37d..84ed4b4 100644 --- a/kernel/fs/schfs/kmount.c +++ b/kernel/fs/schfs/kmount.c @@ -36,6 +36,7 @@ #include "fs.h" #include "buf.h" +#include "file.h" /* SCHFS_DEV, SCHFS_SNAP_DEV */ #include "schfs_int.h" // A SchFS block is 4096 bytes and a buffer-cache block is BSIZE (1024), so each @@ -52,6 +53,16 @@ static struct schfs_kdev kdev; static struct schfs_vol kvol; static int kmounted; +// A mounted snapshot: the same block device with an older tree root, read-only. +// +// A second volume rather than a parameter threaded through all 23 entry points, +// because only the read side can ever address it -- a snapshot cannot be +// written to, so every mutation keeps talking to kvol and needs no change. +static struct schfs_vol ksnapvol; +static int ksnap_mounted; +static char *ksnapcurblk; +static char *ksnapdatablk; + // Block buffers, allocated once at mount. // // Nothing in SchFS may put a block on the stack: a cursor needs a whole @@ -250,6 +261,181 @@ schfs_kmount(uint dev) (int)kvol.generation); } +// Which volume a device number names. The VFS hands an inode to every fs_ops +// call and an inode carries its device, so this is all the dispatch a +// read-only second view needs. +static struct schfs_vol * +vol_for(uint dev) +{ + return dev == SCHFS_SNAP_DEV ? &ksnapvol : &kvol; +} + +static char * +curblk_for(uint dev) +{ + return dev == SCHFS_SNAP_DEV ? ksnapcurblk : kcurblk; +} + +static char * +datablk_for(uint dev) +{ + return dev == SCHFS_SNAP_DEV ? ksnapdatablk : kdatablk; +} + +static int +mounted_for(uint dev) +{ + return dev == SCHFS_SNAP_DEV ? ksnap_mounted : kmounted; +} + +// Mount a snapshot by name, read-only. +// +// The snapshot's records live in the *live* tree, so the name is resolved +// there; what comes back is a root, and the second volume is a copy of the +// first with its tree pointed at it. Nothing else differs -- same device, same +// node format, same checksums. +int +schfs_ksnap_mount(const char *name, unsigned namelen) +{ + if (!kmounted) + return -1; + if (ksnap_mounted) + return -2; /* one at a time, for now */ + + if (ksnapcurblk == 0) ksnapcurblk = kalloc(); + if (ksnapdatablk == 0) ksnapdatablk = kalloc(); + if (ksnapcurblk == 0 || ksnapdatablk == 0) + return -3; + + acquiresleep(&schfs_lock); + + struct schfs_cursor c; + schfs_cursor_init(&c, &kvol.tree, kcurblk); + + struct schfs_key k; + schfs_mkkey(&k, SCHFS_SNAP_OBJECTID, SCHFS_KEY_SNAP, 0); + + int found = 0; + struct schfs_snap sn; + int r = schfs_cursor_seek(&c, &k); + while (r == 0) { + const struct schfs_key *got = schfs_cursor_key(&c); + if (!got || got->objectid != SCHFS_SNAP_OBJECTID || + got->type != SCHFS_KEY_SNAP) + break; + + unsigned len; + const struct schfs_snap *p = (const struct schfs_snap *) + schfs_cursor_val(&c, &len); + if (p && len == sizeof(*p)) { + unsigned i = 0; + while (i < namelen && i < SCHFS_SNAP_NAME_MAX && p->name[i] == name[i]) + i++; + if (i == namelen && + (namelen == SCHFS_SNAP_NAME_MAX || p->name[namelen] == 0)) { + sn = *p; + found = 1; + break; + } + } + r = schfs_cursor_next(&c); + } + + if (!found) { + releasesleep(&schfs_lock); + return -4; + } + + // Same volume, older root. The device, the space map and the block callbacks + // are all the live volume's -- a snapshot is not a separate filesystem, it is + // a second way into this one. + ksnapvol = kvol; + ksnapvol.tree.root = sn.root; + ksnapvol.tree.root_crc = sn.root_crc; + ksnapvol.tree.root_gen = sn.root_generation; + // Its own scratch, or a read through the snapshot would tread on a cursor + // the live volume still has positioned. + ksnapvol.ctx.a = ksnapcurblk; + + ksnap_mounted = 1; + releasesleep(&schfs_lock); + return 0; +} + +void +schfs_ksnap_unmount(void) +{ + acquiresleep(&schfs_lock); + ksnap_mounted = 0; + releasesleep(&schfs_lock); +} + +uint64 +schfs_ksnap_root(void) +{ + return ksnap_mounted ? ksnapvol.root_objectid : 0; +} + +// The read path, addressable per device so a snapshot can be read through the +// same VFS. Each is the existing call with the volume chosen rather than +// assumed; the originals below stay as the live-volume spelling, because most +// callers have no business knowing there is a choice. +long +schfs_kread_dev(uint dev, uint64_t ino, uint64_t off, void *buf, unsigned n) +{ + if (!mounted_for(dev)) + return -1; + acquiresleep(&schfs_lock); + struct schfs_cursor c; + schfs_cursor_init(&c, &vol_for(dev)->tree, curblk_for(dev)); + long r = schfs_file_read(vol_for(dev), &c, ino, off, buf, n, datablk_for(dev)); + releasesleep(&schfs_lock); + return r; +} + +int +schfs_kstat_dev(uint dev, uint64_t ino, struct schfs_inode *out) +{ + if (!mounted_for(dev)) + return -1; + acquiresleep(&schfs_lock); + struct schfs_cursor c; + schfs_cursor_init(&c, &vol_for(dev)->tree, curblk_for(dev)); + int r = schfs_inode_get(vol_for(dev), &c, ino, out); + releasesleep(&schfs_lock); + return r; +} + +uint64 +schfs_klookup_at_dev(uint dev, uint64_t dir, const char *name, unsigned len) +{ + if (!mounted_for(dev)) + return 0; + acquiresleep(&schfs_lock); + struct schfs_cursor c; + schfs_cursor_init(&c, &vol_for(dev)->tree, curblk_for(dev)); + uint64_t ino = 0; + uint16_t type = 0; + int r = schfs_dir_lookup(vol_for(dev), &c, dir, name, len, &ino, &type); + releasesleep(&schfs_lock); + return r == 0 ? ino : 0; +} + +int +schfs_kreaddir_dev(uint dev, uint64_t dir, + int (*fn)(uint64_t, uint16_t, const char *, unsigned, void *), + void *arg) +{ + if (!mounted_for(dev)) + return -1; + acquiresleep(&schfs_lock); + struct schfs_cursor c; + schfs_cursor_init(&c, &vol_for(dev)->tree, curblk_for(dev)); + int r = schfs_dir_iterate(vol_for(dev), &c, dir, fn, arg); + releasesleep(&schfs_lock); + return r; +} + int schfs_kmounted(void) { diff --git a/kernel/fs/schfs/vfs.c b/kernel/fs/schfs/vfs.c index e7aaac9..89fd7c1 100644 --- a/kernel/fs/schfs/vfs.c +++ b/kernel/fs/schfs/vfs.c @@ -38,6 +38,7 @@ #include "fs.h" #include "buf.h" #include "file.h" +#include "errno.h" #include "schfs_int.h" @@ -75,7 +76,7 @@ schfs_vfs_iload(struct inode *ip) { struct schfs_inode in; - if (schfs_kstat(ip->inum, &in) != 0) { + if (schfs_kstat_dev(ip->dev, ip->inum, &in) != 0) { // An unreadable inode on the root filesystem is a panic in v1 because the // system cannot continue without it. Here it is a mounted volume that may // simply be damaged, so report an empty file and let the caller fail @@ -102,11 +103,23 @@ schfs_vfs_iload(struct inode *ip) ip->valid = 1; } +// Everything below this point mutates, and a snapshot is a view of a tree that +// has already been committed -- there is nowhere for a change to go. Refusing +// here rather than in each schfs_k* call keeps the check next to the reason. +static int +readonly(const struct inode *ip) +{ + return ip->dev == SCHFS_SNAP_DEV; +} + static void schfs_vfs_istore(struct inode *ip) { struct schfs_inode in; + if (readonly(ip)) + return; + // Read-modify-write rather than build-from-scratch: the on-disk record holds // fields the VFS has no slot for (uid, gid, the real mode bits, the // generation), and rebuilding it here would quietly zero every one of them. @@ -130,6 +143,8 @@ schfs_vfs_istore(struct inode *ip) static void schfs_vfs_ifree(struct inode *ip) { + if (readonly(ip)) + return; schfs_kfree(ip->inum); } @@ -168,7 +183,7 @@ schfs_vfs_read(struct inode *ip, int user_dst, uint64 dst, uint64 off, uint n) if (want > BOUNCE) want = BOUNCE; - long got = schfs_kread(ip->inum, off + done, tmp, want); + long got = schfs_kread_dev(ip->dev, ip->inum, off + done, tmp, want); if (got < 0) { kfree(tmp); return done ? (int)done : -1; } if (got == 0) break; /* end of file */ @@ -190,6 +205,9 @@ schfs_vfs_read(struct inode *ip, int user_dst, uint64 dst, uint64 off, uint n) static int schfs_vfs_write(struct inode *ip, int user_src, uint64 src, uint64 off, uint n) { + if (readonly(ip)) + return -EROFS; + char *tmp = kalloc(); if (tmp == 0) return -1; @@ -222,6 +240,8 @@ schfs_vfs_write(struct inode *ip, int user_src, uint64 src, uint64 off, uint n) static void schfs_vfs_truncate(struct inode *ip, uint64 newsize) { + if (readonly(ip)) + return; if (schfs_ktruncate(ip->inum, newsize) == 0) ip->size = newsize; } @@ -246,13 +266,13 @@ schfs_vfs_dirlookup(struct inode *dp, char *name, uint *poff) if (namelen == 2 && name[0] == '.' && name[1] == '.') { struct schfs_inode in; - if (schfs_kstat(dp->inum, &in) != 0) + if (schfs_kstat_dev(dp->dev, dp->inum, &in) != 0) return 0; if (poff) *poff = 0; return iget(dp->dev, in.parent ? in.parent : dp->inum); } - uint64 ino = schfs_klookup_at(dp->inum, name, namelen); + uint64 ino = schfs_klookup_at_dev(dp->dev, dp->inum, name, namelen); if (ino == 0) return 0; // poff is v1's byte offset into the directory file. SchFS finds an entry by @@ -265,13 +285,16 @@ schfs_vfs_dirlookup(struct inode *dp, char *name, uint *poff) static int schfs_vfs_dirlink(struct inode *dp, char *name, uint64 inum) { + if (readonly(dp)) + return -EROFS; + unsigned namelen = 0; while (name[namelen]) namelen++; if (namelen == 0 || namelen > SCHFS_NAME_MAX) return -1; struct schfs_inode in; - if (schfs_kstat(inum, &in) != 0) + if (schfs_kstat_dev(dp->dev, inum, &in) != 0) return -1; int r = schfs_kdiradd(dp->inum, name, namelen, inum, in.type); @@ -291,6 +314,9 @@ schfs_vfs_dirlink(struct inode *dp, char *name, uint64 inum) static int schfs_vfs_dirunlink(struct inode *dp, char *name, uint off) { + if (readonly(dp)) + return -EROFS; + (void)off; unsigned namelen = 0; while (name[namelen]) namelen++; @@ -399,7 +425,7 @@ schfs_vfs_getdents(struct inode *dp, char *out, int cap, uint64 *poff) g.written += reclen; } - if (schfs_kreaddir(dp->inum, gd_entry, &g) != 0 && g.written == 0) + if (schfs_kreaddir_dev(dp->dev, dp->inum, gd_entry, &g) != 0 && g.written == 0) return -1; *poff = g.index; @@ -408,6 +434,10 @@ schfs_vfs_getdents(struct inode *dp, char *out, int cap, uint64 *poff) /* ------------------------------------------------------------------ */ +// One table, not two. Every entry already takes an inode and every inode +// carries its device, so the same functions serve the live volume and a mounted +// snapshot -- the read path picks the volume, the write path refuses. A second +// table would be the same pointers with a different name on it. const struct fs_ops schfs_ops = { .name = "schfs", .iload = schfs_vfs_iload, diff --git a/kernel/sys/sys_fs.c b/kernel/sys/sys_fs.c index 49fc4fe..c4d90e3 100644 --- a/kernel/sys/sys_fs.c +++ b/kernel/sys/sys_fs.c @@ -164,13 +164,26 @@ at_nameiparent(int dirfd, char *path, char *name) } static struct inode* -createat(int dirfd, char *path, short type, short major, short minor) +createat_x(int dirfd, char *path, short type, short major, short minor, + int *rofs) { struct inode *ip, *dp; char name[DIRSIZ]; uint off; if (!(dp = at_nameiparent(dirfd, path, name))) return 0; ilock(dp); + // Nothing below this point tolerates a refusal: ialloc and dirlink both + // panic on failure, because on a writable filesystem they cannot fail for + // any reason the caller could have avoided. + // + // The flag lets the caller report EROFS. Returning 0 alone made a refused + // write say "No such file or directory", which is what a shell prints for + // every create failure and tells the user nothing about why. + if (fs_readonly(dp->dev)) { + iunlockput(dp); + if (rofs) *rofs = 1; + return 0; + } if ((ip = dirlookup(dp, name, &off)) != 0) { iunlockput(dp); ilock(ip); @@ -202,10 +215,16 @@ createat(int dirfd, char *path, short type, short major, short minor) return ip; } +static struct inode* +createat(int dirfd, char *path, short type, short major, short minor) +{ + return createat_x(dirfd, path, type, major, minor, 0); +} + static struct inode* create(char *path, short type, short major, short minor) { - return createat(AT_FDCWD, path, type, major, minor); + return createat_x(AT_FDCWD, path, type, major, minor, 0); } // ----------------------------------------------------------------------- @@ -449,8 +468,14 @@ addr_t sys_open(void) struct inode *ex = namei(path); if (ex) { iput(ex); end_op(); return -EEXIST; } } - ip = (omode & O_EXTENT) ? create(path, T_EXTENT, 0, 0) : create(path, T_FILE, 0, 0); - if (!ip) { end_op(); return -ENOENT; } + int rofs = 0; + ip = createat_x(AT_FDCWD, path, + (omode & O_EXTENT) ? T_EXTENT : T_FILE, 0, 0, &rofs); + // A refusal because the filesystem is read-only is not "no such file". + // A shell prints the same sentence for every create failure, so + // returning ENOENT here made writing to a snapshot look like a missing + // directory. + if (!ip) { end_op(); return rofs ? -EROFS : -ENOENT; } if ((omode & O_TRUNC) && (ip->type == T_FILE || ip->type == T_EXTENT)) itrunc(ip); } else { @@ -1371,7 +1396,6 @@ addr_t sys_mount(void) if (argstr(0, &source) < 0 || argstr(1, &target) < 0 || argstr(2, &fstype) < 0) return -EFAULT; - (void)source; // proc: a synthetic filesystem, so there is no source device to check and // no volume to have found at boot. `mount -t proc proc /proc`. @@ -1385,12 +1409,46 @@ addr_t sys_mount(void) // told no rather than quietly handed this. if (strncmp(fstype, "schfs", 6) != 0) return -ENODEV; + + // `mount -t schfs @name /mnt` mounts a snapshot, read-only. + // + // The source finally means something. It was ignored outright before -- + // there was one volume and nothing to choose between -- and a snapshot is + // the first second thing to mount. `@` rather than a path because a + // snapshot is not a device: there is no node in /dev for it and there + // should not be, since it lives inside the volume that is already mounted. + int snapmount = 0; + if (source && source[0] == '@') { + const char *nm = source + 1; + unsigned nlen = 0; + while (nm[nlen] && nlen < 64) + nlen++; + if (nlen == 0) + return -EINVAL; + + // Distinct codes, because "resource busy" for every possible failure + // tells the caller nothing and cost an hour of guessing once already. + int r = schfs_ksnap_mount(nm, nlen); + if (r != 0) { + cprintf("schfs: snapshot mount '%s' failed (%d)\n", nm, r); + switch (r) { + case -1: return -ENODEV; /* no volume mounted to look in */ + case -2: return -EBUSY; /* a snapshot is already mounted */ + case -3: return -ENOMEM; /* no scratch blocks */ + default: return -ENOENT; /* no snapshot by that name */ + } + } + snapmount = 1; + } if (!schfs_kmounted()) return -ENODEV; - uint64 root = schfs_kroot(); - if (root == 0) + uint64 root = snapmount ? schfs_ksnap_root() : schfs_kroot(); + if (root == 0) { + if (snapmount) + schfs_ksnap_unmount(); return -ENODEV; + } begin_op(); struct inode *dp = namei(target); @@ -1402,12 +1460,23 @@ addr_t sys_mount(void) iunlockput(dp); end_op(); - // Mounting SchFS onto a directory that is already on SchFS would let an - // unmount strand the covered directory behind its own mount. - if (dev == SCHFS_DEV) + // Mounting a filesystem onto a directory that is already on *it* would let + // an unmount strand the covered directory behind its own mount: umount + // names the mounted root, and a self-mount makes that ambiguous. + // + // This read `dev == SCHFS_DEV`, which was the same thing while SchFS was + // the only volume there could be. A snapshot is a second device, and its + // mount point is necessarily on the live root -- there is nowhere else to + // put it -- so the old spelling refused every snapshot mount. + uint fsdev = snapmount ? SCHFS_SNAP_DEV : SCHFS_DEV; + if (dev == fsdev) return -EBUSY; - - return mount_add(dev, inum, SCHFS_DEV, root) == 0 ? 0 : -EBUSY; + if (mount_add(dev, inum, fsdev, root) != 0) { + if (snapmount) + schfs_ksnap_unmount(); + return -EBUSY; + } + return 0; } addr_t sys_umount2(void)