Most hobby operating systems are built on top of
something: a bootloader crate that hides the transition into
long mode, a uart_16550 crate for the serial port,
an x86_64 crate for the page tables and interrupt
descriptors, spin for the mutex. That is a
perfectly reasonable way to get an operating system running
quickly — but it also means the most interesting ten
percent of the work happens inside somebody else's code.
Sword Operating System takes the opposite approach. The 16-bit
boot sector, the 16→32→64-bit trampoline, the GDT and
IDT, the VGA and framebuffer drivers, the IDE and AHCI disk
drivers, the filesystem, the scheduler, and even the
cryptography (SHA-256, HMAC, PBKDF2) are all written from
scratch, in this repository, with zero external
crates. It is no_std. The kernel does now
own a small hand-written heap (added as part of the userspace
arc described below — the ELF loader needs it to build
error messages), but the tables the kernel keeps (the file
table, the user table, the task table) are still fixed-size
static arrays, the same discipline a microcontroller firmware
would use — and the three programs that actually run in
ring 3 are no_std and have no
allocator at all.
That constraint is not nostalgia for its own sake. It is what makes the Incident Log further down the page possible at all: when something breaks, there is nowhere else in the stack to go looking.
Every byte that runs in real mode, protected mode, and long mode was written by hand for this project.
A full technical audit of this codebase wrapped up in July 2026: 30-plus real defects found and fixed, ranging from a filesystem block allocator that could silently corrupt a different file than the one being written, to a guaranteed stack overflow on real UEFI hardware, to a handful of authentication weaknesses around the user table. None of that is presented here as “now it's bug-free.” A project that hand-writes its own mutex, IDT, and cryptography stack accumulates real defects as a simple consequence of its own ambition — finding and fixing them honestly is the actual engineering work. Every fix from this pass landed with a comment in the source explaining the bug it was written against, so it doesn't quietly come back.
One gap the audit could not close: hardware coverage. Every fix has been re-verified end-to-end on the BIOS boot path on real hardware. The UEFI path builds cleanly and every change to it has been reasoned through by hand, but it has not yet been re-run start-to-finish on a physical UEFI machine since these fixes landed.
The same kernel is reachable through three different boot paths, because three different kinds of hardware — plus one emulator — each disagree about how a machine is supposed to wake up.
| Image | Boot mode | Partition scheme | Built by |
|---|---|---|---|
sos.img |
BIOS / Legacy | raw, no partition table | hand-written 512-byte MBR (stage1.asm) |
sos.iso |
BIOS / Legacy, via El Torito | ISO 9660 + hybrid MBR | a from-scratch ISO builder — no mkisofs, no xorriso |
sos_uefi.img |
UEFI only | GPT + FAT32 ESP | a separate x86_64-unknown-uefi binary, hand-speaking just enough of the UEFI Boot Services protocol to read its own kernel off disk |
The kernel draws the same 80×25 console through two
entirely different code paths, and which one runs is decided at
boot. Under BIOS, it writes character codes directly into
0xB8000 — classic VGA text mode, and the
hardware turns those bytes into glyphs on its own. Under UEFI,
that memory window simply does not exist on real firmware, so
the bootloader instead hands the kernel the firmware's graphics
framebuffer (the Graphics Output Protocol), and the kernel
draws every glyph itself, pixel by pixel, with its own bitmap
font.
QEMU's emulated hardware happens to support classic VGA text mode even when booted via UEFI, because the emulated card is a genuine Bochs-VBE-capable VGA chip underneath. Real UEFI laptops are not that generous. The first time this kernel booted on an actual UEFI notebook, it came up perfectly — keyboard, disk, shell, all of it — and showed nothing whatsoever on the screen, because it was faithfully writing to a memory address that nobody on that machine was reading.
That per-pixel drawing also used to make scrolling visibly slow on real UEFI hardware: advancing one line meant re-rasterizing all 1,920 glyphs on screen from the bitmap font, roughly a million individual pixel writes per line feed at 1080p. The console now moves whole scanlines instead of redrawing every glyph — the difference between a laggy terminal and a normal one, on the same firmware.
Until this audit, AHCI/SATA disks only worked when SOS booted via UEFI. The reason lived one layer below the driver itself: stage2, the 16→32→64-bit trampoline, only ever identity-mapped the first 4 MB of physical memory, and an AHCI controller's memory-mapped registers sit well above that on real hardware. Touching them from the BIOS path was an instant page fault, so the driver was gated to UEFI only — which meant any modern PC booted in Legacy mode, still the far more common case in the wild, saw no usable disk at all: nothing persisted, and the installer had nowhere to install to. It was the single largest functional gap in the project.
stage2 now builds three additional page directories and identity-maps a full 4 GB, so AHCI/SATA registers are reachable from either boot path, and both now have the same disk support.
sos_uefi.img dropped from 315 MB to 72 MB,
and the smallest disk it can install onto from roughly
300 MB down to about 70 MB. FAT32, the format required
for the EFI System Partition, refuses to format a volume with
fewer than 65,525 clusters; at the previous 4 KB cluster
size, that floor forced a 268 MB partition no matter how
little the ESP actually held. Shrinking the cluster size to
1 KB moves the same floor down to 64 MB.
Once the trampoline lands the CPU in 64-bit mode, the kernel's
own _start takes over and rebuilds everything the
trampoline had only improvised: its own Global Descriptor
Table (the segments the CPU actually trusts from here on), its
own Interrupt Descriptor Table (256 gates — one per CPU
exception and hardware IRQ), and its own remapping of the two
chained 8259 PIC controllers. The timer and keyboard IRQs
arrive on vectors 8–15 by default, which collide
head-on with CPU exceptions like the double-fault and
general-protection vectors — so the very first thing the
kernel does with the PIC is move them out of the way, onto
vectors 32–47.
This audit also added a Task State Segment with an Interrupt Stack Table: the double-fault and page-fault handlers now run on their own dedicated stack, instead of continuing on whatever stack was active when the fault hit. That is the difference between a readable diagnostic message when the kernel corrupts its own stack, and a silent triple fault that just resets the machine with no clue why.
From there, the Programmable Interval Timer is the closest
thing this kernel has to a pulse: reprogrammed to fire one
hundred times a second, its handler does nothing more than
increment a tick counter, and that single counter is what
every notion of elapsed time in the system —
sleep, disk-command timeouts, the scheduler's own
bookkeeping — is built on.
Multitasking stays deliberately modest: a cooperative,
round-robin scheduler in which each task owns a small
dedicated stack and voluntarily hands control back with
yield_now(). There is no preemption, no
priorities, no timer-driven context switch — just enough
to run a background task alongside the interactive shell
without either one blocking the other.
The audit added the three pieces a kernel needs before it can
host userland at all, in the strict order they have to come
up: a physical memory manager
(mm::pmm) that harvests UEFI's free-RAM map into a
bump allocator; a kernel-owned virtual memory
manager (mm::paging) that replaces the
firmware's inherited page tables with ones the kernel built
itself and so can safely hand out high userland addresses
without clobbering its own identity map; and a heap
(mm::heap) on top of the PMM, used narrowly —
most kernel state still lives in fixed-size statics. pmm → paging → heap
is the order mm::init() runs them in, and the order
that matters: the heap is page-mapped, and paging needs the PMM
to give it pages.
The file the bootloader actually reads off disk,
kernel.bin, does not contain the kernel's
.bss section at all. Any static
without an explicit non-zero initializer — the IDT
table, the scheduler's task list, every fixed-size buffer in
the shell — occupies memory once the kernel is running,
but zero bytes in the file on disk. The very first line of
_start, before anything else runs, is a
hand-written zero_bss() that walks from the
linker-provided _bss_start to _bss_end
and blanks it by hand. Skip that step, and every one of those
structures boots with whatever happened to already be sitting
in RAM.
For most of this project's life, that also meant .bss
had almost nowhere to grow on the BIOS boot path: it started
right after the kernel's own loaded code and ran up against the
VGA memory window at 0xA0000, leaving only about
20 KB of headroom. A completely unrelated one-line change
— doubling Papyrus's text buffer from 256 KB to
512 KB — would have grown .bss straight
through that ceiling and started corrupting VGA memory, breaking
the BIOS boot silently while the UEFI build, which has no such
low-memory ceiling, kept working and testing green. The fix
relocates the kernel's .bss above the 1 MiB
mark, growing the BIOS path's headroom from about 20 KB to
640 KB, and build.ps1 now checks both boot
paths so a regression like that fails loudly instead of only on
somebody's BIOS machine.
Up to this audit, the most interesting code in the kernel
all ran at CPL=0. There was no userspace, no syscall
boundary, and no reason to have one — the shell
dispatched commands that lived in the same address space as
the kernel itself. That is now no longer true. The kernel
builds its own page tables, drops into ring 3 with a
hand-written iretq frame, parses a real compiled
ELF, and services a small syscall ABI for it when it traps
back. Three programs — hello,
papyrus, sush — are built as
standalone no_std ELF64 executables, embedded
into the kernel via include_bytes! at compile
time, and loaded as real ring-3 processes. The whole arc is
UEFI only: it needs mm::paging, which needs a
real firmware memory map. On the BIOS build, the same commands
print a controlled "not available" message rather than
reaching for hardware that is not there.
enter_ring3 is hand-written assembly for the same
reason context_switch is: the moment of actually
dropping CPL requires a frame on the stack laid out exactly the
way iretq expects (SS, RSP, RFLAGS, CS, RIP, in
that order), with the ring-3 selectors gdt::load() installed at
boot baked into CS and SS — there is no compiler intrinsic
for that, and Rust's x86-interrupt ABI gives no way
to redirect the eventual return. So the kernel saves the six
callee-saved registers and its own RSP, builds that frame, sets
IF=1 in the flags it pushes (ring-3 code starts with interrupts
enabled), and iretqs. The return is just as
deliberate: vector 0x80 is an
interrupt-gate-at-DPL-3, so ring-3 code reaches it with a
plain int 0x80; a cmp rax, 0 in the
handler decides whether that trap is SYS_EXIT (abandon ring 3
entirely, restore the saved RSP and registers, ret)
or any other syscall (dispatch in ring 0, then iretq
right back where the int 0x80 came from).
The ABI itself borrows its register choice from Linux x86-64
on purpose: rax is the call number,
rdi/rsi/rdx/r10/r8 carry up to five arguments
(R10 instead of RCX for the fourth, exactly as
syscall clobbers RCX and R11 in Linux — this
kernel uses int 0x80 instead, but the convention
costs nothing and avoids inventing a second one), and the
return value lands in rax. RBX, RBP, R12–R15
and RSP are deliberately never touched by the handler's
argument shuffle — they survive a syscall exactly as an
ordinary call would leave them, which the Rust compiler relies
on for callee-saved locals.
| # | Name | Signature | What it does |
|---|---|---|---|
0 | SYS_EXIT | — | Abandons ring 3, resumes the caller of enter_ring3 |
1 | SYS_WRITE | (ptr, len) | Prints a UTF-8 byte range to the console |
2 | SYS_READKEY | () | Non-blocking pop from the keyboard queue; 0 means "no key waiting" |
3 | SYS_READFILE | (path, plen, buf, cap) | Reads a whole file into a caller-owned buffer; 0 if missing |
4 | SYS_WRITEFILE | (path, plen, buf, n) | Replaces a file's contents (creates it if needed) |
5 | SYS_PUT_ROW_STR | (row, col, color, ptr, len) | Draws a string at a screen position, in one of three palette colours |
6 | SYS_CLEAR_ROW | (row, color) | Blanks an entire screen row |
7 | SYS_SET_CURSOR | (row, col, offset) | Repositions the logical cursor |
8 | SYS_RUN_LINE | (line, len, err, cap) | Tokenizes and dispatches a shell line, exactly like the interactive shell — the foundation SuSH is built on |
9 | SYS_GET_CURSOR | () | Returns (row << 32) | col; lets a ring-3 program do its own visual backspace |
The ELF loader (kernel/src/elf.rs) parses
ELF64 static executables field by field, with explicit bounds
checks before every slice index — the same discipline
sfs.rs applies to on-disk binary formats nobody
controls, and for the same reason: a malformed or truncated
file fails with a readable ElfError, never a
panic, and there is no transmute anywhere in
it. Each PT_LOAD segment is read onto fresh
PMM pages, zeroed, the p_filesz bytes copied in
(.bss stays zero), and mapped with USER
(and WRITABLE when the segment says so) at the
virtual address its own linker script chose.
The three programs used to share a single fixed virtual
address (0x4000000000) on the assumption that
"only one ring-3 program runs at a time." SYS_RUN_LINE
breaks that assumption on purpose: a running SuSH script can
dispatch a line like papyrus file.txt as one of
its OWN lines, which loads a SECOND program into ring 3 while
the first (SuSH) is merely paused, not finished. These are
ET_EXEC binaries with no relocations, so their
machine code contains absolute addresses baked in at link
time — reusing the same address would have the inner
program overwrite the outer program's still-needed
code/data/stack. Each program now has its own disjoint range:
| Program | Code base | Stack base | Stack | Purpose |
|---|---|---|---|---|
hello | 0x40_0000_0000 | 0x50_0000_0000 | 16 KB | The minimal syscall proof — Hello from ring 3! |
papyrus | 0x41_0000_0000 | 0x51_0000_0000 | 16 KB | The full-screen editor, moved out of the kernel (see below) |
sush | 0x42_0000_0000 | 0x52_0000_0000 | 256 KB | The interpreter, moved out of the kernel; larger stack because it recursesthat's how sush otherscript.sush is handled |
The one case that layout alone cannot solve — a SuSH
script invoking sush another.sush, i.e. the
SAME program nested inside itself, which would still collide
with itself — is handled differently: userland/sush
treats sush <path> as a built-in of the
interpreter (alongside if/while/read)
and recurses with an ordinary Rust function call inside the
SAME ring-3 excursion. No second ELF is loaded, no second
enter_ring3 happens. The nesting limit is eight
deep, enforced by one counter in enter_ring3_guarded
— now the only way into ring 3, so the two commands
that previously reached the raw assembly directly
(ring3test/ring3hello) cannot bypass
it the way they used to.
Closing a Papyrus session with Ctrl+Q used to lock up the
whole machine. The cause is a one-instruction detail of how
x86_64 privilege gates work: vector 0x80 is an
interrupt gate (Type 0xE), and the CPU hardware-clears
IF on entry to one, exactly as for a hardware IRQ. The normal
syscall path restores IF via its own iretq, which
pops ring 3's saved RFLAGS (IF=1, forced by
enter_ring3). The SYS_EXIT path never executes
an iretq at all — it discards that frame
and does a plain ret — so IF stayed
cleared for the rest of the kernel's life the instant any
ring-3 code exited. No keyboard IRQ, no timer tick, nothing,
ever again. The diagnostic commands never surfaced it because
nobody typed right after them; closing Papyrus is exactly
when a user reaches for the keyboard. A single sti
before that ret closed it.
The second is one an earlier audit cleanly missed. Saving the
outer excursion's RSP was necessary but not sufficient,
because the CPU also reloads RSP from TSS.rsp0
on every ring 3→ring 0 transition — with no
notion of nesting. With rsp0 a boot-time
constant, a nested program's first int 0x80
pushed its interrupt frame at exactly the address the outer
excursion's own frame, and the entire live call chain below
it, already occupied — SYS_EXIT then
faithfully popped garbage into the callee-saved registers
and reted to a garbage address.
enter_ring3 now repoints TSS.rsp0
at its own current RSP per nesting level and the SYS_EXIT
path puts the previous value back — the ordinary
"one kernel stack per thread of control" arrangement,
achieved in hand-written assembly.
block.rs is the single door the filesystem and
the installer knock on to read or write a sector, and behind
that door the kernel supports two entirely different disk
controllers: IDE PIO, which moves every byte
through the CPU via port 0x1F0, the way a
machine from the IDE era would; and AHCI/SATA,
located by walking the PCI bus, driven through memory-mapped
registers and transferred by DMA — the way essentially
every machine built in the last fifteen years actually works.
As of this audit both are available from either boot path
— see the Boot section above for why that wasn't always
true.
On top of that sits SFS (Sword FileSystem), a from-scratch persistent filesystem with a real directory hierarchy and a FAT-like block table that doubles as both the free-space allocator and each file's own chain of blocks.
SOS will not format a disk that isn't its own.
The one thing every earlier version of SFS could do but never
undo was making a directory: mkdir created a
permanent entry, with no rmdir to remove it, so
every directory's block stayed allocated forever even once it
was empty. This audit adds rmdir.
Writing a file was also far more expensive than it needed to be. The block table lives in 512-byte sectors, but each write only touches the 4 bytes belonging to one file's chain entry — so saving a 256 KB file, 512 blocks of data, cost roughly 2,560 sector operations, a full sector read-or-rewrite of the block table for every single block written. A one-sector write-back cache in front of the block table brings that down to about 520.
At boot, SFS only ever does one of three things: mount its own superblock if it finds one, format a disk that is provably blank, or — on finding an MBR, a GPT table, or filesystem debris belonging to something else entirely — stop and report it, untouched. That third case used to be untestable: before the AHCI driver existed, the only disk QEMU ever exposed for testing was an empty scratch image, so a bug that formatted "anything unrecognized" was invisible. The moment AHCI could see a real internal disk, that same bug would have erased a stranger's Windows partition the first time they booted the USB drive out of curiosity.
The interactive layer is a single-line editor with a real cursor (arrow keys, Home/End/Delete, insertion at any position — not only at the end), command history, and a scrollback buffer pageable with Shift+Up/Down without losing the live prompt.
$ cat count.sush set i 0 while $i < 5 echo tick $i set i $i + 1 endwhile
SuSH began as a way to replay a fixed list of
shell commands, and grew into an actual small language:
variables with $name substitution, integer
arithmetic inside set,
if/else/endif,
while/endwhile, a blocking
read for interactive scripts, and comments —
all interpreted directly over a flat script buffer with no
heap allocation, and abortable with Ctrl+C, so a script whose
loop condition never goes false doesn't require a reboot to
escape.
Papyrus is the full-screen counterpart: a text editor that takes over the entire display to edit a file in place, with cursor navigation aware of multi-byte UTF-8 so accented text never gets split mid-character, Tab-to-indent that snaps to the next four-column stop, and Ctrl+S/Ctrl+Q to save and quit.
Papyrus used to keep its entire editing buffer, 256 KB, on the stack. On the BIOS path the kernel's own stack is generous enough that this was invisible. On UEFI, it was a guaranteed overflow: the UEFI loader handed control to the kernel without ever setting up its own stack pointer, so the kernel simply kept running on whatever stack the firmware itself was using — and the UEFI specification only promises 128 KB of that. A 256 KB local buffer on top of it was arithmetically certain to run past the end. The fix has two halves: the loader now reserves and switches to a full 1 MB stack of its own before jumping into the kernel, and Papyrus no longer keeps its buffer on the stack at all.
Both of these tools used to live inside the kernel as ordinary
ring-0 code — shell/editor.rs and
shell/sush.rs, neither of which exists anymore.
They are now standalone no_std ELF64 executables
in userland/papyrus/ and userland/sush/,
embedded into the kernel at compile time and loaded as real
ring-3 processes (see the Userspace section above). The shell
command papyrus file.txt no longer calls editor
code directly — it maps a one-page argument buffer,
hands the file path to it through enter_ring3's
single u64 argument, and the editor reads the
file with SYS_READFILE, draws to the screen
with SYS_PUT_ROW_STR/SYS_CLEAR_ROW,
and saves with SYS_WRITEFILE. SuSH is the same:
every line that is not a built-in of the interpreter goes to
the kernel through SYS_RUN_LINE, so the
interpreter no longer reimplements any shell command itself.
The cost is documented and accepted: ring-3 code cannot
execute hlt, so both programs poll the keyboard
queue in a tight loop instead of parking — full CPU
while they wait for a key.
The keyboard driver is the only module in the kernel that
knows what a scancode is. It decodes Scan Code Set 1 off the
PS/2 controller — modifiers, extended keys behind the
0xE0 prefix, two full layouts (US and Latin
American Spanish) — and hands the rest of the system an
already-resolved key: a Unicode character or a named special
key, never a raw code.
The LatAm layout also implements genuine dead keys: striking the acute-accent/diaeresis position prints nothing yet — it arms a pending state that the next keystroke resolves, producing an accented vowel on a compatible letter, or the bare accent mark followed by whatever was actually typed if it wasn't one.
| Scancode | Key | Note |
|---|---|---|
0x1A | ´ / ¨ (LatAm only) | dead key — combines with the following vowel |
0x2A / 0x36 | Left / Right Shift | ignored when prefixed by 0xE0 — see the incident log |
0x56 | < / > | the ISO key beside Left Shift, absent from US ANSI boards |
The audit added a PS/2 mouse driver (drivers/mouse.rs)
alongside the keyboard, speaking the same controller's aux
channel. Movement and buttons come off the IRQ as a small
packet and are exposed to the kernel as a single
MouseEvent. There is a cursor — but no
classic-VGA cursor was possible here: the text-mode VGA
window at 0xB8000 that the BIOS path uses has no
concept of a mouse pointer. The cursor only exists on the
UEFI path, where the framebuffer driver (drivers/fb.rs)
actually owns every pixel on the screen and the cursor can be
drawn as a small bitmap, moved, and restored byte-for-byte
underneath. That is part of why the framebuffer primitives
were extended at the same time: not just a glyph rasteriser
any more, but per-row and per-pixel drawing primitives the
mouse cursor — and the ring-3 drawing syscalls —
can build on.
The newest driver speaks network rather than disk: a virtio-net
device, located by walking the PCI bus, brought up through the
legacy/transitional I/O-BAR interface (the same one QEMU's
virtio-net-pci speaks by default, so no extra
flags are needed on the emulator command line). Phase 1 is
discovery, bring-up, and MAC address read-out — the
driver recognises the device, resets it, negotiates the
feature bits it understands, and prints what it found.
The actual RX and TX queues are not implemented yet:
there is no ARP, no IP, no ICMP, no ping. The
netinfo shell command shows the device's MAC and
the negotiated features, which is what phase 1 is for —
proving the device handshake works end to end before the
harder half of the driver comes in. QEMU's user-mode
networking (SLIRP, outbound NAT only) gives it something to
find without touching any real host interface or needing
elevated privileges.
Accounts authenticate against PBKDF2-HMAC-SHA256, and all three layers — SHA-256, HMAC, and PBKDF2 itself — are implemented from scratch, with no hardware acceleration and no crate standing in for any of it. Each account gets its own 16-byte salt, so two identical passwords never produce the same stored key, and verification runs in constant time: the comparison walks the entire derived key regardless of where the first mismatched byte falls, so a login attempt's timing cannot leak how much of a guessed password was already correct.
This replaced an earlier, unsalted FNV-1a hash: fast, general purpose, and exactly wrong for passwords — cheap enough to try billions of guesses a second on a commodity GPU. PBKDF2 is slow on purpose (ten thousand rounds of HMAC-SHA256 per attempt): imperceptible for one interactive login, expensive enough to blunt an offline brute force.
This audit also made login about twice as fast without weakening that derivation at all: PBKDF2's inner loop was rebuilding HMAC's key schedule from scratch on every one of the ten thousand iterations, even though the key never changes between them. Precomputing that schedule once and reusing it across all ten thousand rounds produces the identical bytes in roughly half the time.
A handful of other authentication weaknesses were found and
closed in the same pass. A timing oracle in the login path let
an attacker tell a valid username from an invalid one purely by
how long the “Login incorrect” response took to
arrive. The default root account's salt was practically the
same on every fresh install, because it was derived from the
boot timer's tick count at install time — a value that
barely varies from one boot to the next. Passwords typed into
useradd, passwd, and login
were being retained in plaintext in the shell's own command
history. And diskread, the raw sector-read command,
didn't require administrator privileges, so any logged-in user
could dump the account table off the disk sector by sector
regardless of their own permissions.
Separately, the audit also flagged — without yet closing — that deleting the system's last administrator account leaves it permanently locked out, with no way to recover from inside the running system.
Until this audit, the project had exactly zero automated tests
— every incident in the log below was found by booting the
thing and watching what broke. That changed to 37 tests across
two suites, and build.ps1 now runs them as its
first step: if they don't pass, nothing gets built.
common/diskfmt (12 tests) formats a disk entirely in memory and checks the exact bytes a real firmware would read back: GPT header CRCs recomputed the same way firmware recomputes them, agreement between the primary and backup partition tables, partitions that stay inside the disk's usable range, and the FAT32 cluster-count floor behind the UEFI image-size fix described above.
common/kernel_logic_tests (25 tests) pulls in
the kernel's own source files directly with Rust's
#[path] attribute — the same code that boots,
not a copy of it — and checks the hand-written
cryptography against published test vectors: SHA-256 against
FIPS 180-4, HMAC against RFC 4231, and PBKDF2 built on
both. It's the first time that stack has been checked against
anything other than “does login still work.”
Every row below is a real bug, found on a real boot. Most of them only ever surfaced once actual hardware, not QEMU, was in the loop — though a growing share now get caught earlier, by the automated tests described above.
| Symptom | Root cause | Fix |
|---|---|---|
Passwords reverted to root/root on every reboot |
auth::load() compared the size of what it
read against the expected 664-byte user table —
but the filesystem delivers file contents in chunks of
up to 512 bytes, not the whole file at once, so that
comparison could never succeed for a two-block table.
|
Accumulate the chunks into a buffer before comparing the total size. |
| Silent reset loop after enlarging the Papyrus text buffer |
The kernel's growing .bss overlapped the
BIOS trampoline's own page tables and boot stack, fixed
at 0x70000/0x90000 for a much
smaller kernel. zero_bss() overwrote them
while the CPU was still using them.
|
Relocate the trampoline's page tables and stack to 0x200000/0x300000; extend the identity map to 4 MB. |
| The LatAm keyboard's </> key typed nothing |
Its scancode, 0x56, falls outside the
0x00–0x39 range the layout tables cover — a
key US ANSI boards don't even have, which is why it went
unnoticed under every US-layout test.
|
A dedicated case for scancode 0x56, independent of the layout tables. |
| Up-arrow scrolled the screen instead of recalling history |
With Num Lock active, the Up arrow arrives as
E0 2A E0 48 instead of a plain
E0 48 — a decades-old compatibility
trick the driver mistook for a real Shift press.
|
A fake shift only counts as real when it arrives without the 0xE0 prefix. |
| Booted perfectly under OVMF, invisible on real firmware | rust-lld leaves the UEFI PE header's CheckSum field at zero; stricter real firmware refused to load the image at all. |
The build script computes and patches in a real PE checksum after linking. |
| Writing a file to a fragmented disk silently corrupted a different, existing file | The block allocator checked that a candidate block was free, then went on to claim a different block — one block off from the one it had just checked — as the actual allocation. | Allocate the exact block that was checked as free. A test bench confirmed the before/after: 19 of 20 blocks corrupted before the fix, 0 of 20 after. |
| Closing Papyrus with Ctrl+Q froze the whole machine |
Vector 0x80 is an interrupt gate (Type 0xE),
which the CPU hardware-clears IF on entry to just like for
a hardware IRQ. The normal syscall path restores IF via its
own iretq, which pops ring 3's saved
RFLAGS (IF=1, forced by enter_ring3). The
SYS_EXIT escape path never executes an iretq
at all — it discards that frame and does a plain
ret — so IF stayed cleared for the rest
of the kernel's life the instant any ring-3 program
exited. No timer tick, no keyboard IRQ, ever again. The
diagnostic commands never surfaced it because nobody typed
right after them; closing Papyrus is exactly when a user
reaches for the keyboard.
|
A single sti before that ret. Its one-instruction interrupt shadow makes it safe to place directly before the return. |
A SuSH script line running papyrus file.txt crashed the inner program on its first syscall |
TSS.rsp0 was set once at boot, at the top of
the kernel's ring-0 entry stack, and never moved. The CPU
reloads RSP from rsp0 on every ring 3→ring 0
transition with no notion of nesting, so a nested
program's first int 0x80 pushed its interrupt
frame at exactly the address the OUTER excursion's own
frame — and its entire live call chain below it
— already occupied. SYS_EXIT then faithfully popped
garbage into the callee-saved registers and
reted to a garbage address.
|
enter_ring3 repoints TSS.rsp0 at its own current RSP per nesting level, and the SYS_EXIT path restores the previous value — the ordinary "one kernel stack per thread of control" arrangement, achieved in hand-written assembly. |
A ring-3 program passing a kernel address to SYS_READFILE's buffer could write anywhere in ring 0 memory; SYS_WRITE could read it back and print it — the user table, salts and PBKDF2 keys included |
The module comment claimed a wild pointer "faults reading unmapped memory, caught by the existing #PF handler rather than corrupting something silently." True for a wild pointer, false for the case that mattered: the entire kernel lives inside the 0–4 GiB identity map, which is PRESENT and WRITABLE, and these syscalls run in ring 0 where the USER bit of a leaf entry imposes nothing. | One user_range_ok bound against USER_MIN = 0x40_0000_0000 on every ring-3-supplied pointer — a single comparison per argument that rejects anything in the kernel range. Per-process address-space tracking would be the real fix and does not exist yet; this closes the "kernel memory as a syscall I/O target" class today. |