Every read descended the B-tree twice per block -- once for the inode and once for the extent -- so an 834 KB file cost 418 descents, each a tree walk plus a 4 KB copy out of the buffer cache per node visited. A sequential read asks for the same inode every time and usually stays inside one extent, so both answers were already known and thrown away. One entry each, not a table: the pattern being optimised is a single file read straight through, and one entry catches all of it without needing an eviction policy. A second reader simply misses. Correctness rests entirely on invalidation, so it is blunt: all five write entry points in objw.c call schfs_cache_invalidate() before touching anything. A cache that is merely usually right would hand back another file's data. The volume pointer is part of the key because the host tools can hold two images open at once. Measured in the guest, bracketed inside one command line, TCG: read 834 KB 1.73s -> 0.31s (5.6x, together with the BOUNCE fix) ls /bin cold 2.80s -> 1.40s (2x) ls /bin warm 0.70s -> 0.70s (unchanged) Warm ls is untouched on purpose-by-consequence: it is 241 separate inodes, so a one-entry cache never hits. That cost is the per-entry lookup path -- dirlookup plus iload, each its own descent -- and it is where the next order of magnitude is, not here. schfs-check passes, including the round-trip; bootcheck 108 [ok]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
507 lines
17 KiB
C
507 lines
17 KiB
C
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// SchFS object layer, read side: inodes, directories and file data.
|
|
//
|
|
// Everything here is a record in the one B-tree, which is the point of the
|
|
// btrfs-style design — there is no inode table, no directory block format and
|
|
// no indirect-block scheme to get wrong, only key types:
|
|
//
|
|
// (ino, INODE, 0) struct schfs_inode
|
|
// (dir, DIRENT, hash) packed list of schfs_dirent_rec + name
|
|
// (ino, EXTENT, fileblk) struct schfs_extent
|
|
// (CSUM, CSUM, diskblk) uint32 CRC32c of that data block
|
|
//
|
|
// Kernel code reaches all of this through a caller-supplied cursor, so no
|
|
// function here puts a 4096-byte block on the stack.
|
|
|
|
#include "schfs_int.h"
|
|
|
|
#ifdef SCHFS_HOSTED
|
|
#include <string.h>
|
|
#else
|
|
#include "types.h"
|
|
#include "defs.h"
|
|
#endif
|
|
|
|
// Point a cursor at the volume's current tree, keeping the caller's block
|
|
// buffer. Called before every operation rather than once at open, because the
|
|
// tree root moves on every commit under copy-on-write: a cursor that cached the
|
|
// old root would keep reading a stale — though perfectly valid — filesystem.
|
|
static void
|
|
cur_bind(struct schfs_cursor *c, struct schfs_vol *v)
|
|
{
|
|
c->t = &v->tree;
|
|
c->depth = 0;
|
|
c->valid = 0;
|
|
}
|
|
|
|
void
|
|
schfs_mkkey(struct schfs_key *k, uint64_t objectid, uint8_t type,
|
|
uint64_t offset)
|
|
{
|
|
memset(k, 0, sizeof(*k));
|
|
k->objectid = objectid;
|
|
k->type = type;
|
|
k->offset = offset;
|
|
}
|
|
|
|
// FNV-1a, 64-bit. Directory lookup is a tree descent on this value, so it wants
|
|
// to spread short, highly similar names ("ls", "ln", "cp") across the keyspace.
|
|
// FNV does that, is a dozen lines, and needs no table — worth more here than
|
|
// the marginally better distribution of something larger.
|
|
//
|
|
// Collisions are handled by the packed dirent list rather than assumed away;
|
|
// see struct schfs_dirent_rec.
|
|
// Test-only: narrows the hash so collisions become common.
|
|
//
|
|
// With a full 64-bit hash, two names in one directory colliding is so unlikely
|
|
// that the packed-list code which handles it would never run — and untested
|
|
// code that only executes on rare inputs is exactly the code that loses a file
|
|
// years later. Setting this to a small mask makes every directory a pile of
|
|
// collisions, so the round-trip test exercises the path properly. Always ~0
|
|
// in the kernel and in real images.
|
|
uint64_t schfs_hash_mask = ~0ULL;
|
|
|
|
uint64_t
|
|
schfs_namehash(const char *name, unsigned len)
|
|
{
|
|
uint64_t h = 1469598103934665603ULL; /* FNV offset basis */
|
|
for (unsigned i = 0; i < len; i++) {
|
|
h ^= (uint8_t)name[i];
|
|
h *= 1099511628211ULL; /* FNV prime */
|
|
}
|
|
// Key ordering is by this value, so a directory's entries come back in an
|
|
// order that has nothing to do with their names. That is fine for readdir
|
|
// (POSIX promises no order) and is what makes lookup one descent.
|
|
return h & schfs_hash_mask;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Inodes */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
int
|
|
schfs_inode_get(struct schfs_vol *v, struct schfs_cursor *c,
|
|
uint64_t ino, struct schfs_inode *out)
|
|
{
|
|
struct schfs_key k;
|
|
schfs_mkkey(&k, ino, SCHFS_KEY_INODE, 0);
|
|
|
|
cur_bind(c, v);
|
|
unsigned len = sizeof(*out);
|
|
int r = schfs_btree_lookup_cur(c, &k, out, &len);
|
|
if (r != 0)
|
|
return r;
|
|
// A short record means an image written by an older or broken tool. Reject
|
|
// it rather than acting on a half-filled struct.
|
|
if (len != sizeof(*out))
|
|
return -2;
|
|
return 0;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Directories */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
// Walk a packed dirent list, calling fn per entry. Stops early if fn returns
|
|
// non-zero. Returns fn's value, or -2 if the list is malformed.
|
|
static int
|
|
dirent_walk(const void *val, unsigned len,
|
|
int (*fn)(const struct schfs_dirent_rec *, const char *, void *),
|
|
void *arg)
|
|
{
|
|
unsigned off = 0;
|
|
while (off < len) {
|
|
if (len - off < sizeof(struct schfs_dirent_rec))
|
|
return -2;
|
|
const struct schfs_dirent_rec *d =
|
|
(const struct schfs_dirent_rec *)((const char *)val + off);
|
|
unsigned step = SCHFS_DIRENT_LEN(d->namelen);
|
|
// Bounds-check before touching the name: a corrupt namelen would
|
|
// otherwise read past the end of the block.
|
|
if (d->namelen == 0 || d->namelen > SCHFS_NAME_MAX || off + step > len)
|
|
return -2;
|
|
int r = fn(d, (const char *)d + sizeof(*d), arg);
|
|
if (r)
|
|
return r;
|
|
off += step;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
struct find_ctx {
|
|
const char *name;
|
|
unsigned namelen;
|
|
uint64_t ino;
|
|
uint16_t type;
|
|
};
|
|
|
|
static int
|
|
find_one(const struct schfs_dirent_rec *d, const char *nm, void *arg)
|
|
{
|
|
struct find_ctx *f = (struct find_ctx *)arg;
|
|
if (d->namelen != f->namelen)
|
|
return 0;
|
|
if (memcmp(nm, f->name, f->namelen) != 0)
|
|
return 0;
|
|
f->ino = d->objectid;
|
|
f->type = d->type;
|
|
return 1; /* found; stop */
|
|
}
|
|
|
|
int
|
|
schfs_dir_lookup(struct schfs_vol *v, struct schfs_cursor *c,
|
|
uint64_t dir, const char *name, unsigned namelen,
|
|
uint64_t *out_ino, uint16_t *out_type)
|
|
{
|
|
if (namelen == 0 || namelen > SCHFS_NAME_MAX)
|
|
return -1;
|
|
|
|
struct schfs_key k;
|
|
schfs_mkkey(&k, dir, SCHFS_KEY_DIRENT, schfs_namehash(name, namelen));
|
|
|
|
cur_bind(c, v);
|
|
int r = schfs_cursor_seek(c, &k);
|
|
if (r == -2)
|
|
return -2;
|
|
if (r != 0)
|
|
return -1;
|
|
|
|
const struct schfs_key *got = schfs_cursor_key(c);
|
|
if (!got || schfs_key_cmp(got, &k) != 0)
|
|
return -1;
|
|
|
|
unsigned len;
|
|
const void *val = schfs_cursor_val(c, &len);
|
|
if (!val)
|
|
return -2;
|
|
|
|
struct find_ctx f;
|
|
f.name = name; f.namelen = namelen; f.ino = 0; f.type = 0;
|
|
r = dirent_walk(val, len, find_one, &f);
|
|
if (r == -2)
|
|
return -2;
|
|
if (r != 1)
|
|
return -1; /* hash matched, name did not */
|
|
|
|
if (out_ino) *out_ino = f.ino;
|
|
if (out_type) *out_type = f.type;
|
|
return 0;
|
|
}
|
|
|
|
struct iter_ctx {
|
|
int (*fn)(uint64_t, uint16_t, const char *, unsigned, void *);
|
|
void *arg;
|
|
struct schfs_cursor *cur;
|
|
uint64_t dir;
|
|
int stop;
|
|
};
|
|
|
|
// Hand each entry of one packed dirent list to the caller.
|
|
//
|
|
// The list lives in the cursor's block buffer, and almost every readdir caller
|
|
// immediately looks each entry up — to stat it, to recurse into it — through a
|
|
// cursor that may well share that buffer. Walking the list in place meant the
|
|
// name and every record after it turned to garbage the moment the callback
|
|
// touched the filesystem, which is how an extracted tree once ended up with
|
|
// directories reparented under their own children.
|
|
//
|
|
// The obvious fix, copying the whole item aside, costs SCHFS_MAX_INLINE (just
|
|
// over 1 KB) of stack — a quarter of the kernel's entire 4096-byte stack, on a
|
|
// path that also runs under procread's 2 KB buffer. So only the current entry
|
|
// is copied, and the item is re-fetched from the cursor each time round:
|
|
// schfs_cursor_val re-reads the block, which restores whatever the callback
|
|
// disturbed.
|
|
static int
|
|
iter_item(const struct schfs_key *k, const void *val, unsigned len, void *arg)
|
|
{
|
|
struct iter_ctx *ic = (struct iter_ctx *)arg;
|
|
(void)val;
|
|
|
|
// The range scan runs until something stops it, so stopping at the first
|
|
// key that is no longer this directory's is what bounds the walk.
|
|
if (k->objectid != ic->dir || k->type != SCHFS_KEY_DIRENT)
|
|
return 1;
|
|
if (len > SCHFS_MAX_INLINE)
|
|
return 1;
|
|
|
|
unsigned off = 0;
|
|
while (off < len) {
|
|
unsigned now;
|
|
const char *v = (const char *)schfs_cursor_val(ic->cur, &now);
|
|
if (!v || now != len) {
|
|
ic->stop = -2;
|
|
return 1;
|
|
}
|
|
if (len - off < sizeof(struct schfs_dirent_rec)) {
|
|
ic->stop = -2;
|
|
return 1;
|
|
}
|
|
|
|
const struct schfs_dirent_rec *d =
|
|
(const struct schfs_dirent_rec *)(v + off);
|
|
unsigned namelen = d->namelen;
|
|
unsigned step = SCHFS_DIRENT_LEN(namelen);
|
|
// Bounds-check before touching the name: a corrupt namelen would
|
|
// otherwise read past the end of the block.
|
|
if (namelen == 0 || namelen > SCHFS_NAME_MAX || off + step > len) {
|
|
ic->stop = -2;
|
|
return 1;
|
|
}
|
|
|
|
char name[SCHFS_NAME_MAX];
|
|
uint64_t oid = d->objectid;
|
|
uint16_t type = d->type;
|
|
memmove(name, (const char *)d + sizeof(*d), namelen);
|
|
|
|
int r = ic->fn(oid, type, name, namelen, ic->arg);
|
|
if (r) {
|
|
ic->stop = r;
|
|
return 1;
|
|
}
|
|
off += step;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
int
|
|
schfs_dir_iterate(struct schfs_vol *v, struct schfs_cursor *c, uint64_t dir,
|
|
int (*fn)(uint64_t, uint16_t, const char *, unsigned, void *),
|
|
void *arg)
|
|
{
|
|
struct schfs_key start;
|
|
schfs_mkkey(&start, dir, SCHFS_KEY_DIRENT, 0);
|
|
|
|
struct iter_ctx ic;
|
|
ic.fn = fn; ic.arg = arg; ic.cur = c; ic.dir = dir; ic.stop = 0;
|
|
|
|
cur_bind(c, v);
|
|
int r = schfs_btree_range(c, &start, iter_item, &ic);
|
|
if (r == -2)
|
|
return -2;
|
|
return ic.stop == 1 ? 0 : ic.stop;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* File data */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* --------------------------------------------------------------------------
|
|
* Read caches: the last inode, and the last extent.
|
|
*
|
|
* Every read used to descend the B-tree twice per block -- once for the inode
|
|
* and once for the extent -- so an 834 KB file cost 418 descents, each a tree
|
|
* walk plus a 4 KB copy out of the buffer cache per node visited. Sequential
|
|
* reads ask for the same inode every time and usually stay inside one extent,
|
|
* so both answers are already known.
|
|
*
|
|
* One entry each, not a table: the access pattern being optimised is one file
|
|
* read straight through, and a single entry catches all of it without needing
|
|
* eviction policy or a hash. A second reader simply misses.
|
|
*
|
|
* Correctness rests entirely on invalidation, so it is deliberately blunt:
|
|
* every write entry point in objw.c calls schfs_cache_invalidate() before it
|
|
* touches anything. A cache that is merely usually right is worse than none at
|
|
* all here -- a stale extent hands back another file's data.
|
|
*
|
|
* The volume pointer is part of the key because the host tools can hold more
|
|
* than one volume open, and two images would otherwise share these.
|
|
* ------------------------------------------------------------------------ */
|
|
static struct {
|
|
int valid;
|
|
const struct schfs_vol *vol;
|
|
uint64_t ino;
|
|
struct schfs_inode in;
|
|
} ino_cache;
|
|
|
|
static struct {
|
|
int valid;
|
|
const struct schfs_vol *vol;
|
|
uint64_t ino;
|
|
uint64_t first; /* first file block covered */
|
|
uint64_t nblocks; /* how many, from `first` */
|
|
uint64_t disk; /* disk block backing `first` */
|
|
} ext_cache;
|
|
|
|
void
|
|
schfs_cache_invalidate(void)
|
|
{
|
|
ino_cache.valid = 0;
|
|
ext_cache.valid = 0;
|
|
}
|
|
|
|
// Map a file block to a disk block. Returns 1 if mapped, 0 for a hole, -2 on
|
|
// corruption. Extents are keyed by the file block they start at, so the record
|
|
// governing block B is the last one at or before (ino, EXTENT, B).
|
|
static int
|
|
extent_map(struct schfs_vol *v, struct schfs_cursor *c, uint64_t ino,
|
|
uint64_t fblk, uint64_t *out)
|
|
{
|
|
if (ext_cache.valid && ext_cache.vol == v && ext_cache.ino == ino &&
|
|
fblk >= ext_cache.first && fblk < ext_cache.first + ext_cache.nblocks) {
|
|
*out = ext_cache.disk + (fblk - ext_cache.first);
|
|
return 1;
|
|
}
|
|
|
|
struct schfs_key k;
|
|
schfs_mkkey(&k, ino, SCHFS_KEY_EXTENT, fblk);
|
|
|
|
cur_bind(c, v);
|
|
int r = schfs_btree_lookup_le(c, &k);
|
|
if (r == -2)
|
|
return -2;
|
|
if (r != 0)
|
|
return 0;
|
|
|
|
const struct schfs_key *got = schfs_cursor_key(c);
|
|
if (!got)
|
|
return 0;
|
|
// lookup_le can land on some other object's records entirely; a sparse file
|
|
// whose first extent starts past this block looks the same way.
|
|
if (got->objectid != ino || got->type != SCHFS_KEY_EXTENT)
|
|
return 0;
|
|
|
|
unsigned len;
|
|
const struct schfs_extent *e =
|
|
(const struct schfs_extent *)schfs_cursor_val(c, &len);
|
|
if (!e || len != sizeof(*e))
|
|
return -2;
|
|
|
|
if (fblk >= got->offset + e->nblocks)
|
|
return 0; /* hole past the end of this extent */
|
|
|
|
ext_cache.valid = 1;
|
|
ext_cache.vol = v;
|
|
ext_cache.ino = ino;
|
|
ext_cache.first = got->offset;
|
|
ext_cache.nblocks = e->nblocks;
|
|
ext_cache.disk = e->disk_block;
|
|
|
|
*out = e->disk_block + (fblk - got->offset);
|
|
return 1;
|
|
}
|
|
|
|
// Verify a data block against its checksum record. Tree nodes carry their
|
|
// checksum in the parent pointer; data blocks have no parent, so the check must
|
|
// be an explicit lookup. Returns 0 if good, -2 if it fails, and 0 as well when
|
|
// no record exists — an image may legitimately predate checksums for a block,
|
|
// and refusing to read data over a *missing* checksum would turn an upgrade
|
|
// into a total loss.
|
|
static int
|
|
csum_check(struct schfs_vol *v, struct schfs_cursor *c, uint64_t dblk,
|
|
const void *data)
|
|
{
|
|
struct schfs_key k;
|
|
schfs_mkkey(&k, SCHFS_CSUM_OBJECTID, SCHFS_KEY_CSUM, dblk);
|
|
|
|
uint32_t want;
|
|
unsigned len = sizeof(want);
|
|
cur_bind(c, v);
|
|
int r = schfs_btree_lookup_cur(c, &k, &want, &len);
|
|
if (r == -1)
|
|
return 0;
|
|
if (r != 0 || len != sizeof(want))
|
|
return -2;
|
|
|
|
if (schfs_crc32c(0, data, SCHFS_BLKSIZE) != want)
|
|
return -2;
|
|
return 0;
|
|
}
|
|
|
|
long
|
|
schfs_file_read(struct schfs_vol *v, struct schfs_cursor *c, uint64_t ino,
|
|
uint64_t off, void *buf, unsigned n, void *dblk)
|
|
{
|
|
struct schfs_inode in;
|
|
if (ino_cache.valid && ino_cache.vol == v && ino_cache.ino == ino) {
|
|
in = ino_cache.in;
|
|
} else {
|
|
int r = schfs_inode_get(v, c, ino, &in);
|
|
if (r != 0)
|
|
return r;
|
|
ino_cache.valid = 1;
|
|
ino_cache.vol = v;
|
|
ino_cache.ino = ino;
|
|
ino_cache.in = in;
|
|
}
|
|
|
|
if (off >= in.size)
|
|
return 0;
|
|
if (off + n > in.size)
|
|
n = (unsigned)(in.size - off);
|
|
|
|
char *dst = (char *)buf;
|
|
unsigned done = 0;
|
|
while (done < n) {
|
|
uint64_t fblk = (off + done) / SCHFS_BLKSIZE;
|
|
unsigned inblk = (unsigned)((off + done) % SCHFS_BLKSIZE);
|
|
unsigned want = SCHFS_BLKSIZE - inblk;
|
|
if (want > n - done)
|
|
want = n - done;
|
|
|
|
uint64_t disk = 0;
|
|
int m = extent_map(v, c, ino, fblk, &disk);
|
|
if (m == -2)
|
|
return -2;
|
|
|
|
if (m == 0) {
|
|
memset(dst + done, 0, want); /* hole reads as zeros */
|
|
} else {
|
|
if (v->dev.bread(v->dev.ctx, disk, dblk) != 0)
|
|
return -2;
|
|
if (csum_check(v, c, disk, dblk) != 0)
|
|
return -2;
|
|
memmove(dst + done, (char *)dblk + inblk, want);
|
|
}
|
|
done += want;
|
|
}
|
|
return (long)done;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Path resolution */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
uint64_t
|
|
schfs_path_lookup(struct schfs_vol *v, struct schfs_cursor *c, const char *path)
|
|
{
|
|
uint64_t cur = v->root_objectid;
|
|
|
|
while (*path == '/')
|
|
path++;
|
|
|
|
while (*path) {
|
|
const char *s = path;
|
|
while (*path && *path != '/')
|
|
path++;
|
|
unsigned len = (unsigned)(path - s);
|
|
while (*path == '/')
|
|
path++;
|
|
|
|
if (len == 1 && s[0] == '.')
|
|
continue;
|
|
if (len == 2 && s[0] == '.' && s[1] == '.') {
|
|
// ".." is synthetic: directories store a parent id rather than an
|
|
// entry, so walking up is an inode read, not a lookup.
|
|
struct schfs_inode in;
|
|
if (schfs_inode_get(v, c, cur, &in) != 0)
|
|
return 0;
|
|
cur = in.parent ? in.parent : v->root_objectid;
|
|
continue;
|
|
}
|
|
|
|
// Every component but the last must be a directory. Checking here is
|
|
// what makes "cat /etc/passwd/x" fail rather than silently succeed.
|
|
struct schfs_inode in;
|
|
if (schfs_inode_get(v, c, cur, &in) != 0)
|
|
return 0;
|
|
if (in.type != SCHFS_T_DIR)
|
|
return 0;
|
|
|
|
uint64_t next;
|
|
if (schfs_dir_lookup(v, c, cur, s, len, &next, 0) != 0)
|
|
return 0;
|
|
cur = next;
|
|
}
|
|
return cur;
|
|
}
|