// 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 #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 */ /* ------------------------------------------------------------------ */ /* Inode cache. * * Sixteen entries, not one. `ls` is the shape that matters: it stats every name * in a directory, so a single entry never hits and the whole directory costs * two tree descents per file -- one to find the name, one to read the inode. * Sixteen is enough that a directory walk keeps hitting while a second reader * elsewhere does not evict everything. * * Round-robin replacement, deliberately: an LRU needs bookkeeping on every hit, * and the access pattern here is a walk rather than a working set, so the * cleverer policy buys nothing. * * Invalidation is blunt and total -- see schfs_cache_invalidate(). */ #define INO_CACHE_N 16 static struct { int valid; const struct schfs_vol *vol; uint64_t ino; struct schfs_inode in; } ino_cache[INO_CACHE_N]; static unsigned ino_cache_next; int schfs_inode_get(struct schfs_vol *v, struct schfs_cursor *c, uint64_t ino, struct schfs_inode *out) { for (unsigned i = 0; i < INO_CACHE_N; i++) { if (ino_cache[i].valid && ino_cache[i].vol == v && ino_cache[i].ino == ino) { *out = ino_cache[i].in; return 0; } } 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; unsigned slot = ino_cache_next++ % INO_CACHE_N; ino_cache[slot].valid = 1; ino_cache[slot].vol = v; ino_cache[slot].ino = ino; ino_cache[slot].in = *out; 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: recently-read inodes (see schfs_inode_get) 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; 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) { for (unsigned i = 0; i < INO_CACHE_N; i++) ino_cache[i].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) { /* schfs_inode_get caches for itself now, so this is a hit on every chunk * after the first without a second cache here to keep coherent. */ struct schfs_inode in; int r = schfs_inode_get(v, c, ino, &in); if (r != 0) return r; 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; }