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 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-08-10 21:10:59 +02:00
commit 3919f1446e
6 changed files with 345 additions and 18 deletions

View file

@ -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,