| kaboom | kernel | fs | syscalls | userland | build |
this page is kfs: kaboom's own on-disk filesystem, hand-rolled and not a port of anything -- not fat12/fat32, not tape-kernel's own ffs (clean-room, feature-inherited-not-copied, since tape-kernel is gplv3 and this is fmc), not unix's inode format even where it looks similar. it also covers virtfs, the small layer that makes /proc and /int show live kernel state instead of real disk content. read kernel for how exec.nsc drives a lot of this (path resolution, permission checks) on the way to running a program.
block 0 is the superblock, blocks 1 through inode_count are the inode table (one inode per whole 512-byte block -- see below), the next block is the root directory's own data, and everything past that is file data blocks, bump-allocated and never freed. every field in every one of these structures is a full 8-byte word, deliberately: unlike gdt/idt entries, whose byte layout is dictated by the cpu, or elf, whose layout is dictated by the format spec, kfs's on-disk format is entirely kaboom's own invention, so there's no reason to fight nsc's pointer model (*ptr is always a full 8-byte load/store, no byte or halfword deref, no struct byte-packing) when word-aligning every field sidesteps the problem for free.
an inode is a whole 512-byte block -- not several inodes packed per block the way a real unix filesystem would do it. that costs disk space nobody's short on yet, and buys back something real: reading or writing an inode is a plain, direct block read or write, no sub-block offset math, no read-modify-write of a block shared with unrelated inodes. the fields: type (0 free, 1 file, 2 directory), permission bitmask, size in bytes, 60 direct block pointers, and one indirect block pointer. 60 direct blocks covers 30720 bytes on its own; the indirect pointer, once a file actually needs a 61st block, points at one more 512-byte block holding up to 64 further block pointers, for 60 + 64 = 124 blocks = 63488 bytes as the practical ceiling of one indirect level. that cap has been raised three times already -- 64 bytes/5 direct blocks, then 128/13, then the current 512/60 -- each time because kaboom's own coreutils binaries genuinely outgrew the old one. adding one level of indirection instead of a fourth flat raise is the first change here that scales past "make the number bigger again"; double indirection is a real, known follow-up once something actually needs a bigger file than 63488 bytes, not something pretended to exist now.
kfs permissions are a single bitmask digit, r=1 w=2 x=4, so rwx is 7 -- the same scheme for files and directories both, stored directly in the inode, no separate owner/group/other split anywhere. chmod.nsc's own comment says the important part plainly: this is not unix's three-digit owner/group/other octal, and treating it like one is a real, documented point of confusion -- kaboom has no user accounts to have separate owner/group/other permissions for in the first place, so one digit is the whole permission model, unconditional, with no owner/root gate to check against first. there's exactly one user here, so the stored bitmask is simply the whole answer, every time.
that distinction bit someone for real, not hypothetically: several places that predate real enforcement -- mk/disk.pl's own bin-binary/doc/virtfs-placeholder creation, and kfs_save's create-if-missing default -- stored the literal permission "6" believing it meant "rw", which is true in real unix octal (r=4 w=2) but wrong in kaboom's own scheme, where 6 is actually "wx" with r entirely missing. harmless before enforcement existed to actually check it; it would have made every binary on the disk, sh included, and every doc file unreadable and unexecutable the moment enforcement turned on, without anyone asking for that. fixed by correcting the defaults (disk.pl's binaries/docs/placeholders now get 7, kfs_save's create-default now gets 3) rather than by touching the bitmask itself.
chmod (the userspace command) takes either the raw digit (chmod 3 file) or the same bitmask spelled out as an rwx letter triplet with - for an unset bit (chmod rw- file) -- exactly the form perms prints back, so whatever perms shows you is also valid chmod input, unchanged either way. perms itself only exists because nothing before it could read a permission bit back out to userspace at all: kfs_check_perm could check one, kfs_chmod could overwrite one, but neither could hand one back for a command to display.
kfs_dir_find/kfs_dir_find_in only ever look inside one given directory; they can't walk into a subdirectory themselves. kfs_resolve is the function that actually walks a real path one component at a time, absolute (/a/b/c, starting at root) or relative (a/b/c or a bare name, starting at cwd), calling kfs_dir_find_in once per component. every intermediate component has to already exist and be a directory; the final component can be anything, since a file is exactly what cat/open want to find at the end of a path. kfs_resolve_parent is the same walk, except it stops one component early and hands back the parent directory's own lba and inode number plus the byte offset where the final component starts -- what mkdir/create/rm/rmdir/save all actually need, since they're operating a dirent into or out of the parent, not resolving the (possibly not-yet-existing, for create/mkdir) final inode itself.
say the gap plainly: .. is not supported. there's no parent pointer stored anywhere -- not in an inode, not in a dirent -- so there's nothing for .. to resolve against even if the parser recognized it, and today it doesn't even try. this is a real, known limitation, not a secret held back from this page. kfs_cd works around needing a parent pointer at all by keeping kfs_cwd_path as its own separately-tracked printable string, updated by hand on every cd (replaced wholesale on an absolute path, appended to on a relative one) rather than ever being reconstructed by walking parent pointers backward -- because no parent pointer exists to walk. pwd just reads that string back; it isn't derived from anything else.
descending through an intermediate directory during either resolve function costs a real permission check, not just a type check: the component has to actually be a directory, and it has to have the execute bit set -- real unix's own "traverse" permission -- checked with kfs_check_perm(inode, 4) before the walk is allowed to continue through it. that check is why a directory's x bit matters independently of its r bit at all; see the enforcement section below for the exact list of what depends on which bit.
a directory's data used to be exactly one 512-byte block: sixteen 32-byte dirent slots, each an 8-byte inode number field plus a 24-byte null-padded name, and that was the whole directory, full stop. it broke for a real reason, not a hypothetical one: /bin outgrew sixteen entries the moment a few more coreutils got added, and mkdir: directory (lba N) full -- disk.img.def's own guard for exactly the single-block case -- fired for real.
the fix chains blocks instead of growing the inode: slots 0 through 14 (offsets 0 through 448) are real dirents, one fewer than before, and slot 15 (offset 480) is never a real dirent at all -- its full 8-byte word is either the all-ones sentinel (no next block yet) or the lba of the next block in the chain. kfs_dir_find_in/kfs_dir_list_in/kfs_dir_add_in/kfs_dir_remove_in all walk this chain, only moving to the next block once the current one is exhausted, and kfs_dir_add_in only bump-allocates and links on a fresh block once the current last block in the chain is genuinely full -- a directory grows by one block at a time, exactly when it needs to, the same lazy-allocation shape kfs_write_file's own indirect block already uses.
growing a directory's inode to hold multiple direct block pointers, the way a file's inode already does, was the other option and got passed over on purpose: chaining needed zero changes to kfs_mkfs, to mk/disk.pl's own block format, or to anything that already treated a directory as "an lba", like kfs_cwd_dir_lba/kfs_bin_dir_lba -- a freshly formatted block's slot 15 was already the all-ones sentinel every slot starts as, so "no next block yet" was already true of every directory block that existed before chaining did. only the four dir_*_in functions above needed to actually change.
removing entries doesn't shrink the chain back down, either: kfs_rmdir's own "is this directory empty" check has to walk every block in the chain, not just the first, because a directory that once grew a second block and then had every entry in it removed again is still a chain of now-empty blocks, not back to one -- the same bump-allocator-never-frees philosophy as every other allocator in this kernel (kalloc, the fs block/inode allocators), documented rather than fixed.
permissions are genuinely enforced, not just stored -- kfs_check_perm(inode_num, bit) is the one real check every enforcement point below shares, and it's the only place in the whole kernel that ever sets kaboom_errno to 1 (EPERM); every public entry point with a permission check of its own resets kaboom_errno to 0 at its own start, so a stale value from an earlier, unrelated failure can never leak into a later call's error log line (see kernel's note on sys_doerror for what actually reads that value).
for a file:
for a directory, it's a genuine three-way split matching real unix semantics, not a kaboom invention:
worth being explicit about a real, already-confirmed edge case here: chmod 0 on a file doesn't stop ls/stat from showing it, and that's correct, not a bug -- real unix never gated seeing a file's own dirent entry on that file's own permission bits either, only on x on the directories leading to it. cat-ing or executing that same chmod-0'd file does correctly fail, and correctly works again after chmod 7 restores it; this was checked directly against a real repro (chmod 0 /bin/touch then ls /bin/touch) rather than assumed.
kfs_chmod/kfs_getperm themselves are unconditional -- no permission check gates changing or reading back a permission bit, because there's no owner or root concept to gate that behind (the same "one user, no split" reasoning as everywhere else in this section). the one user here can always chmod anything.
a handful of names under /proc and /int have their content generated live, at read time, instead of coming from real disk blocks. real, empty placeholder files still exist on disk for each of these -- mk/disk.pl creates them at build time -- purely so kfs_resolve/ls/stat keep working completely unmodified on them. virtfs_read is what fd_open calls first, before ever falling through to a real kfs_read_file, so opening one of these always returns fresh content instead of whatever empty bytes were on disk at mkfs time. kfs_proc_dir_lba/kfs_int_dir_lba are resolved once at mount time, the same way kfs_bin_dir_lba is, so virtfs can recognize "this name's parent is one of these two directories" without re-walking from root on every single open -- and both fall back to root on an old disk image built before /proc//int existed at all, the same fallback kfs_bin_dir_lba already uses, guarded so a root-level file that happens to be named e.g. "version" on such an image isn't wrongly treated as virtual.
/proc/version prints a fixed banner string. /proc/self reports whoever is currently reading it -- plan9's own convention, not a fixed pid -- which falls out for free from how shallow kaboom's process table already is: anything able to open and read a file at all is, by definition, a running process, and since sh itself never reads files directly, that's always the depth-2 command sh is currently running, except right at boot before sh's first command, when depth 1 (sh itself) is the honest answer. /proc/ps is the whole process table, one line per depth that's actually populated -- "ps is literal ls /proc", and ps.nsc itself is nothing more than a hardcoded cat of this file. deliberately not here: a real per-pid subdirectory tree (/proc/1/status and so on) would need virtual directories, not just virtual files -- kfs_resolve/kfs_stat/ls would all have to learn to fake a listing, not just have fd_open call a generator -- a materially bigger change for two processes that are already fully described by one flat /proc/ps line each.
/int/mem reports live heap and fs-arena usage via alloc.nsc's own accessors, so this doesn't need to know the internal layout of either arena. /int/kbd reports live shift/caps-lock state. /int/kfs reformats the same four numbers kfs_info already hands the info command as plain text. /int/cpu reports the vendor and brand strings straight from cpu.nsc's cpuid wrappers -- confirmed against a real qemu boot to report the actual host cpu qemu is emulating, not a hardcoded string.
dispatch itself -- which known parent directory, and which exact name within it -- is a plain if/else chain, checked directly against the literal name each time. that's not laziness: nscc, the compiler, has no function pointers at all. every call site has to name a real function symbol at compile time; there's no way to take a function's address, store it in a variable or a struct field, or call through one. nsc also has no arrays of structs -- only arrays of a single scalar or pointer type -- so a {name, fn} table iterated in a loop isn't buildable in this language as it stands. this was investigated directly against the compiler's own parser and typechecker, not assumed: a real jump table would need an actual nscc compiler change (indirect-call codegen), a much bigger undertaking than de-hardcoding one dispatch function, and arguably works against the same "small, simple" design direction that would motivate wanting a table in the first place. an if/else chain calling a named function per case is already the most direct, minimal shape this language can express for a lookup like this -- restructuring it without real function pointers would only add ceremony, not reduce hardcoding.
