From 2fc166b5507b46f3875e75842491ebe8694a41ae Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 20:39:53 +0800 Subject: [PATCH 01/47] docs: add Phase 3 Plan E implementation plan (FS write + console + shell + utilities) --- ...-phase3-plan-e-fs-write-shell-utilities.md | 4533 +++++++++++++++++ 1 file changed, 4533 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-phase3-plan-e-fs-write-shell-utilities.md diff --git a/docs/superpowers/plans/2026-04-26-phase3-plan-e-fs-write-shell-utilities.md b/docs/superpowers/plans/2026-04-26-phase3-plan-e-fs-write-shell-utilities.md new file mode 100644 index 0000000..a8c6541 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-phase3-plan-e-fs-write-shell-utilities.md @@ -0,0 +1,4533 @@ +# Phase 3 Plan E — FS write path + console fd + shell + utilities (Implementation Plan) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the entire FS write path on top of Phase 3.D's read path, wire the console as fd 0/1/2 with a real cooked-mode line discipline (echo, backspace, `^U`, `^C`, `^D`, `\n` line completion), build the user stdlib (`start.S`, `usys.S`, `ulib.zig`, `uprintf.zig`) so userland can be written in normal Zig instead of naked `_start` inline-asm, and implement the six userland binaries the milestone needs (`init`, `sh`, `ls`, `cat`, `echo`, `mkdir`, `rm`). Add five new syscalls (34 `mkdirat`, 35 `unlinkat`, plus extending 64 `write`, 5000 `set_fg_pid`, 5001 `console_set_mode` from accept-and-discard stubs to real implementations) and one extension (56 `openat` gains `O_CREAT`, `O_TRUNC`, `O_APPEND`). Wire the kill-flag check at every syscall return so `^C` on the foreground pid actually unsticks a sleeping `read`. Build a parallel `shell-fs.img` that installs the new binaries (plus an empty `/tmp` directory for the milestone's `> /tmp/x` redirect) without disturbing 3.D's `fs.img`. Add `tests/e2e/shell.zig` + `tests/e2e/shell_input.txt` + the `e2e-shell` build step that scripts the spec's milestone session via `--input`. The single-hart `kernel-fs.elf` from 3.D is reused unchanged — only the on-disk `/bin/init` differs between the two images. All Phase 1/2/3.A/3.B/3.C/3.D e2e tests keep passing untouched. + +**Architecture:** Plan 3.D left the kernel able to read files from disk but not write them, with `fd 1` and `fd 2` going straight to the UART via a hard-coded `sysWrite` arm and `fd 0` having no source at all (no UART RX in S-mode → no `read(0, …)` ever returns). 3.E fills the entire chain from "user types a key" through "shell forks utility, utility writes a new file, shell reads it back". The console (`console.zig`, NEW) holds a 128-byte circular `input` buffer (`r`/`w`/`e` indices, xv6-style) plus a `mode` (Cooked / Raw) and an `fg_pid` (which proc receives `^C`). The line discipline runs inside `console.feedByte(b)`, called by `uart.isr()` (NEW; runs from `trap.zig`'s S-external arm when PLIC source 10 fires) for every byte drained from the UART RX FIFO. Cooked mode echoes printables, handles `\b` / `\x7f` (backspace), `\x15` (`^U`, kill line), `\x03` (`^C`, kill fg_pid + clear line), `\x04` (`^D`, EOF), and `\n` / `\r` (commit line; wake readers sleeping on `&input.r`). Raw mode bypasses everything and delivers each byte immediately. The kernel calls `console.read(dst_va, n)` (NEW) which loops sleeping on `&input.r` until `input.w != input.r`, then SUM-1 copies bytes out one at a time, breaking on `\n` or buffer full. `console.write(src_va, n)` (NEW) just SUM-1 copies through `uart.writeByte` (same effect as the old `sysWrite` UART arm). The file table (`file.zig`, MODIFIED) gains real `FileType.Console` handling: `file.read` and the new `file.write` switch on `f.type` and dispatch to either the inode path (3.D's `readi` / 3.E's new `writei`) or `console.read` / `console.write`. Three Console File entries are pre-allocated by `kmain` (FS_DEMO arm) and installed into PID 1's `ofile[0..3]` before `exec` — so `init` inherits stdin/stdout/stderr without ever needing to `open("/dev/console")`. Children inherit through `proc.fork`'s existing `file.dup` loop. `sysSetFgPid` (5000) becomes `console.fg_pid = pid`, and `sysConsoleSetMode` (5001) becomes `console.mode = .Cooked / .Raw`. The kill-flag check runs at the bottom of `syscall.dispatch`: if `cur().killed != 0`, jump straight to `proc.exit(-1)` instead of returning to user — and `console.read` checks `cur().killed` after each `proc.sleep` wakeup so `^C` while sleeping in `read(0, …)` actually unsticks the shell. The FS write side adds `inode.iupdate(ip)` (writes the in-memory dinode back to its inode-table block via `bwrite`), `inode.bmap` is extended with a `for_write` flag (when set, lazily allocates direct/indirect blocks from `balloc.alloc`), `inode.writei(ip, src, off, n)` walks each 4-KB chunk via `bmap → bread → memcpy → bwrite → brelse`, growing `ip.dinode.size` and calling `iupdate` on the dinode if size changed, `inode.ialloc(itype)` walks the inode table for a `type == .Free` slot, claims it (writes back via `bwrite`), and returns a new in-memory inode via `iget`. `iput` (MODIFIED) gains the on-zero on-disk truncate path: when `ip.refs == 0` AND `ip.dinode.nlink == 0`, free every direct + indirect data block via `balloc.free`, set `ip.dinode.type = .Free`, call `iupdate` to flush. `dir.dirlink` (MODIFIED — Plan 3.D landed a stub) finds the first slot with `inum == 0` (or appends at `dir.size`) and `writei`s a `DirEntry`. `dir.dirunlink` (NEW) scans for a matching name and zeros its `inum` (no compaction). The new `fs/fsops.zig` layer sits between syscalls and the FS: `fsops.create(path, itype) → ?*InMemInode` does `nameiparent + dirlookup` (idempotent for existing files; rejects existing dirs), allocates a new inode via `ialloc`, calls `dirlink` on the parent, and for `.Dir` types adds `.` and `..` entries to the new directory. `fsops.unlink(path) → i32` does `nameiparent + dirlookup`, checks the entry isn't `.` / `..`, calls `dirunlink`, decrements `nlink`, calls `iupdate`, and `iput`s the inode (which triggers truncate if this was the last link). `sysOpenat` (MODIFIED) recognizes `O_CREAT = 0x40`, `O_TRUNC = 0x200`, `O_APPEND = 0x400` (Linux RV ABI values): on `O_CREAT` if `namei` fails, calls `fsops.create(path, .File)`; on `O_TRUNC` truncates the inode to zero (frees all data blocks via the same path as `iput`-on-unlink); on `O_APPEND` sets the file's `off` to `dinode.size` after `ilock`. `sysWrite` (MODIFIED) drops the hard-coded UART arm and instead routes through `file.write(idx, buf, n)` for every fd — the Console / Inode dispatch happens inside file.zig. `sysMkdirat` (NEW, syscall 34) calls `fsops.create(path, .Dir)`. `sysUnlinkat` (NEW, syscall 35) calls `fsops.unlink(path)`. The userland stdlib lives in `src/kernel/user/lib/` (NEW directory, four files): `start.S` is the canonical RV32 `_start` (parses `argc` from `*sp` and `argv` from `sp+4`, calls `main(argc, argv)`, ecalls `exit`); `usys.S` is one preprocessor-style macro per syscall (~17 stubs, 5 lines each — `li a7, NUM; ecall; ret`); `ulib.zig` is the userspace standard library (`memmove`, `memcmp`, `memset`, `strlen`, `strcmp`, `strncmp`, `atoi`, `getline`); `uprintf.zig` is a 60-line `printf(fd, fmt, ...)` supporting `%d`, `%u`, `%x`, `%s`, `%c`, `%%`. A `build.zig` helper `addUserBinary(name, main_src)` produces an ELF that links `start.o + usys.o + ulib.o + uprintf.o + main.o` against `user_linker.ld`. The seven user binaries are: `init_shell.zig` (NEW, replaces `fs_init.zig` as the on-disk `/bin/init` in `shell-fs.img`; loops `fork → exec("/bin/sh") → wait`); `sh.zig` (NEW, ~350 LoC: read line from fd 0, tokenize on whitespace + `<` `>` `>>` redirects, dispatch to `cd` / `pwd` / `exit` builtins or `fork + exec`; `set_fg_pid(child)` before `wait` and `set_fg_pid(0)` after); `ls.zig` (NEW, ~70 LoC: open path, `fstat`; if Dir, `read` `DirEntry` records and `printf` each name; if File, `printf` size); `cat.zig` (NEW, ~40 LoC: open each arg, copy 4 KB at a time to fd 1; with no args, copy fd 0 → fd 1 until EOF); `echo.zig` (NEW, ~25 LoC: `printf` joined argv + newline); `mkdir.zig` (NEW, ~25 LoC: `mkdirat(0, argv[1])`); `rm.zig` (NEW, ~25 LoC: `unlinkat(0, argv[1], 0)`). `mkfs.zig` (MODIFIED) gains: handling for empty source directories (so `userland/shell-fs/tmp/` becomes an empty `/tmp/` in the image — the host-side `tmp/` carries a `.gitkeep` file that mkfs **skips** because the entry name starts with `.`), and a `--init ` flag so the build can install either `fs_init.elf` (3.D's `fs.img`) or `init_shell.elf` (3.E's `shell-fs.img`) at `/bin/init`. The build adds `kernel-init-shell` (RV32 `init_shell.elf`), `kernel-sh` / `kernel-ls` / `kernel-cat` / `kernel-echo` / `kernel-mkdir` / `kernel-rm` (RV32 utility binaries), `shell-fs-img` (runs mkfs against `userland/shell-fs/` + the new binaries), `shell-fs-img` is **distinct from** `fs-img` — they coexist. `kernel-fs.elf` is reused as-is (no boot config change; the difference between e2e-fs and e2e-shell is purely which `/bin/init` is on disk). `tests/e2e/shell.zig` (NEW, host harness following the `tests/e2e/fs.zig` pattern) spawns `ccc --input shell_input.txt --disk shell-fs.img kernel-fs.elf`, asserts exit 0, asserts stdout contains specific landmark lines (`"$ ls /bin"`, `"sh"`, `"$ echo hi > /tmp/x"`, `"$ cat /tmp/x"`, `"hi"`, `"$ rm /tmp/x"`, `"$ exit"`). `tests/e2e/shell_input.txt` (NEW) carries the spec milestone's input verbatim plus a final `\n` so the shell sees the last line as a complete line. The `e2e-shell` build step wires it. Plan 3.D's `e2e-fs` keeps passing because `fs.img` (with `fs_init.elf` at `/bin/init`) still exists; the `--init` flag default is `fs_init.elf` for backwards compat. + +**Tech Stack:** Zig 0.16.x (pinned in `build.zig.zon`). The userland stdlib (`start.S`, `usys.S`) is RV32 RISC-V assembly (`as` via Zig's bundled LLD). `ulib.zig` and `uprintf.zig` are pure Zig with no `std` imports (matches the existing user-program convention — the user binaries are `freestanding` and don't link libc). `uprintf` writes through the kernel's `write` syscall via `usys.S`, not through Zig's `std.io.Writer`. No new external dependencies. **No emulator code changes** — the UART RX FIFO + `RxPump` already ship from Plan 3.A; Plan 3.E only adds the kernel-side ISR + line discipline that consumes them. + +**Spec reference:** `docs/superpowers/specs/2026-04-25-phase3-multi-process-os-design.md` — Plan 3.E covers spec §Architecture (kernel module `console`, kernel module changes for `file` + `inode` + `dir`, userland `lib/start.S` / `lib/usys.S` / `lib/ulib.zig` / `lib/uprintf.zig`, userland binaries `init` / `sh` / `ls` / `cat` / `echo` / `mkdir` / `rm`), §Privilege & trap model (UART RX path through PLIC IRQ #10, kill-flag at syscall return), §Process model (`Process` struct already extended in 3.D; 3.E only adds the console-fd setup in `kmain`), §Filesystem (write path: `writei`, `bmap` with allocation, `iupdate`, `ialloc`, `iput`-on-zero truncate, `dirlink`, `dirunlink`), §Syscall surface rows for **34 (mkdirat)**, **35 (unlinkat)**, **5000 (set_fg_pid)**, **5001 (console_set_mode)**, with extensions to **56 (openat)** for `O_CREAT` / `O_TRUNC` / `O_APPEND` and to **64 (write)** for file fds, §Userland (line editor / tokenizer / redirects / builtins for `sh`; per-binary LoC budgets), §`mkfs.zig` (extended to install the new binaries + empty `/tmp/`), §Implementation plan decomposition entry **3.E**. The xv6 source (Cox/Kaashoek/Morris MIT) remains the authoritative reference for the console line discipline and the FS write path — when this plan and that source disagree, the spec is right. + +**Plan 3.E scope (subset of Phase 3 spec):** + +- **PLIC kernel-side: enable IRQ #10 (`plic.zig`, MODIFIED — small)** — Add `pub const IRQ_UART_RX: u32 = 10;` next to the existing `IRQ_BLOCK = 1`. The `setPriority` / `enable` / `setThreshold` / `claim` / `complete` API is unchanged from 3.D — the new IRQ is just a constant. + +- **Kernel UART RX (`uart.zig`, MODIFIED)** — Add `readByte` and `isr`: + ```zig + // src/kernel/uart.zig — extends 3.D writeByte with read + ISR. + + pub const UART_BASE: u32 = 0x1000_0000; + pub const UART_RBR: u32 = UART_BASE + 0x0; // receive buffer (read pops FIFO) + pub const UART_LSR: u32 = UART_BASE + 0x5; // line status + pub const LSR_DR: u8 = 1 << 0; // data ready bit + + pub fn readByte() ?u8 { + const lsr: *const volatile u8 = @ptrFromInt(UART_LSR); + if ((lsr.* & LSR_DR) == 0) return null; + const rbr: *const volatile u8 = @ptrFromInt(UART_RBR); + return rbr.*; + } + + /// PLIC src 10 ISR. Drain the FIFO into the console line discipline. + /// Called from trap.zig's S-external dispatch. + pub fn isr() void { + while (readByte()) |b| { + console.feedByte(b); + } + } + ``` + The existing `writeByte` is unchanged. `isr` MUST drain the FIFO completely or the IRQ stays asserted and we re-enter immediately (level-triggered). The console is responsible for discarding bytes if its `input` buffer is full — `feedByte` silently drops in that case. + +- **Console line discipline (NEW: `console.zig`)** — xv6-style cooked + raw modes: + ```zig + pub const ConsoleMode = enum(u32) { Cooked = 0, Raw = 1 }; + pub const INPUT_BUF_SIZE: u32 = 128; + + // Single-hart, single-console — global state is fine. + var input: struct { + buf: [INPUT_BUF_SIZE]u8 = undefined, + r: u32 = 0, // next byte for read syscall + w: u32 = 0, // wake threshold (read can deliver up to here) + e: u32 = 0, // edit position (next slot for line discipline writes) + } = .{}; + var mode: ConsoleMode = .Cooked; + var fg_pid: u32 = 0; + ``` + Line discipline (cooked) maps: + + | Byte | Action | + |--------------|--------| + | `0x08` `0x7F` | Backspace: if `e > w`, decrement `e` and echo `"\b \b"`. | + | `0x15` (`^U`) | Kill line: while `e > w`, decrement `e` and echo `"\b \b"`. | + | `0x03` (`^C`) | Kill: erase current line (like `^U`); echo `"^C\n"`; if `fg_pid != 0`, call `proc.kill(fg_pid)`; advance `r = w` so a pending `read(0, ...)` returns 0 bytes if it was waiting. | + | `0x04` (`^D`) | EOF: commit any pending bytes (`w = e`), wake `&input.r`. The reader sees `r == w` after consuming and returns 0. | + | `\n` `\r` | Commit line: store `\n` at `buf[e++ % INPUT_BUF]`, echo `\n`, `w = e`, wake `&input.r`. | + | `0x20..0x7E` | Printable: store at `buf[e++ % INPUT_BUF]`, echo, no wake. | + | other | Drop. | + + Raw mode bypasses everything: store `b` at `buf[e++ % INPUT_BUF]`, `w = e`, wake — no echo, no special-byte handling. + + All "buf full" checks use wrapping subtraction: `input.e -% input.r < INPUT_BUF_SIZE`. The `-%` operator wraps cleanly past `2^32` so the indices can run forever. + + API (called from kernel): + - `pub fn init() void` — zeroes indices, sets `mode = .Cooked`, `fg_pid = 0`. + - `pub fn setMode(new_mode: u32) void` — `mode = if (new_mode == 0) .Cooked else .Raw`. + - `pub fn setFgPid(pid: u32) void` — `fg_pid = pid`. + - `pub fn feedByte(b: u8) void` — line discipline (called from `uart.isr`). + - `pub fn read(dst_user_va: u32, n: u32) i32` — sleep until `r != w`, SUM-1 copy bytes one-at-a-time, break on `\n` or `n` reached. Returns bytes copied (0 on EOF after `^D`, -1 if killed during sleep). + - `pub fn write(src_user_va: u32, n: u32) i32` — SUM-1 copy each byte to `uart.writeByte`. Returns `n` (always succeeds; UART writes never block in the emulator). + + **Why a circular buffer in kernel rather than in the emulator UART FIFO:** the FIFO is 256 bytes raw; the kernel needs a *line-discipline-processed* buffer (already-handled backspaces, already-erased killed lines). Drawing the boundary at `feedByte` keeps the FIFO simple and the line discipline testable. + +- **S-external dispatch for IRQ #10 (`trap.zig`, MODIFIED — one switch arm)** — In both `m_trap_dispatch_s_forwarded` and `s_trap_dispatch`, add `IRQ_UART_RX` to the `switch (irq)`: + ```zig + switch (irq) { + plic.IRQ_BLOCK => block.isr(), + plic.IRQ_UART_RX => uart.isr(), + else => kprintf.panic("unknown PLIC src: {d}", .{irq}), + } + ``` + +- **PLIC enable for IRQ #10 (`kmain.zig`, MODIFIED — three lines in the FS_DEMO arm)** — After the existing 3.D PLIC setup for `IRQ_BLOCK`, add: + ```zig + plic.setPriority(plic.IRQ_UART_RX, 1); + plic.enable(plic.IRQ_UART_RX); + // (threshold already set to 0 above; same threshold gates both sources) + console.init(); + ``` + +- **Console fds 0/1/2 setup (`kmain.zig`, MODIFIED — installed before exec)** — In the FS_DEMO arm, between `init_p.cwd = 0` and the PLIC setup (or before `proc.exec`), allocate a Console File entry and install three refs into `init_p.ofile[0..3]`: + ```zig + const console_fidx = file.alloc() orelse kprintf.panic("kmain: file.alloc console", .{}); + file.ftable[console_fidx].type = .Console; + file.ftable[console_fidx].ip = null; + file.ftable[console_fidx].off = 0; + // ref_count = 1 from alloc; bring it to 3 (one per fd 0/1/2). + _ = file.dup(console_fidx); + _ = file.dup(console_fidx); + init_p.ofile[0] = console_fidx; + init_p.ofile[1] = console_fidx; + init_p.ofile[2] = console_fidx; + ``` + All three fds share one File entry — `file.dup` just bumps `ref_count`. This is the standard xv6 pattern (init opens `/console` once, then `dup`s to fds 1 and 2). + +- **`file.zig` extensions (MODIFIED)** — Console-aware `read` + new `write`: + - `read(idx, dst_va, n)` (MODIFIED): switch on `f.type`; if `.Console`, return `console.read(dst_va, n)`; if `.Inode`, the existing 3.D code path (ilock + readi + iunlock + SUM-1 copy + bump off). + - `write(idx, src_va, n)` (NEW): switch on `f.type`; if `.Console`, return `console.write(src_va, n)`; if `.Inode`, ilock + writei (with appropriate `f.off` and SUM-1 source) + iunlock + bump `f.off`. Returns bytes / -1. + - `close(idx)` (MODIFIED — existing 3.D code only iputs when `.Inode`; Console close is just the ref-count decrement, no inode action). The existing code already handles this since `if (f.type == .Inode and f.ip != null)` gates the iput — Console entries skip it. **No code change** in `close`. + - `lseek(idx, off, whence)` (MODIFIED): if `f.type == .Console`, return -1 (consoles aren't seekable). Existing 3.D code path is fine for `.Inode`. + - `fstat(idx, stat_va)` (MODIFIED): if `f.type == .Console`, write `Stat { type = 2, size = 0 }` and return 0. Existing 3.D code path is fine for `.Inode`. + +- **`syscall.zig` updates (MODIFIED)** — Five changes plus killed-check: + 1. `sysWrite` no longer hard-codes the UART path; routes everything through `file.write`: + ```zig + fn sysWrite(fd: u32, buf_va: u32, len: u32) i32 { + if (fd >= proc.NOFILE) return -1; + const idx = proc.cur().ofile[fd]; + if (idx == 0) return -1; + return file.write(idx, buf_va, len); + } + ``` + (Returns `i32` now instead of `u32` — also update the dispatch arm to `@bitCast(sysWrite(...))`.) + 2. `sysRead` is unchanged in shape; `file.read` is the dispatcher. + 3. `sysSetFgPid` (5000) replaces the stub: + ```zig + fn sysSetFgPid(pid: u32) u32 { + console.setFgPid(pid); + return 0; + } + ``` + 4. `sysConsoleSetMode` (5001) replaces the stub: + ```zig + fn sysConsoleSetMode(mode: u32) u32 { + console.setMode(mode); + return 0; + } + ``` + 5. `sysOpenat` (56) gains O_CREAT / O_TRUNC / O_APPEND handling: + ```zig + pub const O_RDONLY: u32 = 0x000; + pub const O_WRONLY: u32 = 0x001; + pub const O_RDWR: u32 = 0x002; + pub const O_CREAT: u32 = 0x040; + pub const O_TRUNC: u32 = 0x200; + pub const O_APPEND: u32 = 0x400; + + fn sysOpenat(dirfd: u32, path_va: u32, flags: u32) i32 { + _ = dirfd; + + var pbuf: [path_mod.MAX_PATH]u8 = undefined; + const p = copyStrFromUser(path_va, &pbuf) orelse return -1; + + // namei first; on miss, maybe O_CREAT. + const ip = path_mod.namei(p) orelse blk: { + if ((flags & O_CREAT) == 0) return -1; + break :blk fsops.create(p, .File) orelse return -1; + }; + + // O_TRUNC on a file: free all blocks, set size=0, iupdate. + if ((flags & O_TRUNC) != 0) { + inode.ilock(ip); + if (ip.dinode.type == .File) inode.itrunc(ip); + inode.iunlock(ip); + } + + const fidx = file.alloc() orelse { + inode.iput(ip); + return -1; + }; + file.ftable[fidx].type = .Inode; + file.ftable[fidx].ip = ip; + file.ftable[fidx].off = if ((flags & O_APPEND) != 0) blk: { + inode.ilock(ip); + const sz = ip.dinode.size; + inode.iunlock(ip); + break :blk sz; + } else 0; + + const cur_p = proc.cur(); + var fd: u32 = 0; + while (fd < proc.NOFILE) : (fd += 1) { + if (cur_p.ofile[fd] == 0) { + cur_p.ofile[fd] = fidx; + return @intCast(fd); + } + } + file.close(fidx); + return -1; + } + ``` + 6. New syscall **34 mkdirat**: + ```zig + fn sysMkdirat(dirfd: u32, path_va: u32) i32 { + _ = dirfd; + var pbuf: [path_mod.MAX_PATH]u8 = undefined; + const p = copyStrFromUser(path_va, &pbuf) orelse return -1; + const ip = fsops.create(p, .Dir) orelse return -1; + inode.iput(ip); + return 0; + } + ``` + 7. New syscall **35 unlinkat**: + ```zig + fn sysUnlinkat(dirfd: u32, path_va: u32, flags: u32) i32 { + _ = dirfd; _ = flags; + var pbuf: [path_mod.MAX_PATH]u8 = undefined; + const p = copyStrFromUser(path_va, &pbuf) orelse return -1; + return fsops.unlink(p); + } + ``` + 8. **Killed-flag check** appended to `dispatch`: + ```zig + pub fn dispatch(tf: *trap.TrapFrame) void { + switch (tf.a7) { + // ... existing arms ... + 34 => tf.a0 = @bitCast(sysMkdirat(tf.a0, tf.a1)), + 35 => tf.a0 = @bitCast(sysUnlinkat(tf.a0, tf.a1, tf.a2)), + // ... other arms unchanged ... + } + if (proc.cur().killed != 0) { + proc.exit(-1); // does not return + } + } + ``` + +- **`fs/inode.zig` extensions (MODIFIED)** — Five additions: + 1. `pub fn iupdate(ip: *InMemInode) void` — write `ip.dinode` back to its slot in the inode-table block: + ```zig + pub fn iupdate(ip: *InMemInode) void { + const blk = layout.INODE_START_BLK + ip.inum / layout.INODES_PER_BLOCK; + const slot = ip.inum % layout.INODES_PER_BLOCK; + const buf = bufcache.bread(blk); + const inodes: [*]layout.DiskInode = @ptrCast(@alignCast(&buf.data[0])); + inodes[slot] = ip.dinode; + bufcache.bwrite(buf); + bufcache.brelse(buf); + } + ``` + 2. `bmap` (MODIFIED) — add `for_write` flag; when true and `addrs[i] == 0`, allocate via `balloc.alloc`: + ```zig + pub fn bmap(ip: *InMemInode, bn: u32, for_write: bool) u32 { + if (bn < layout.NDIRECT) { + var addr = ip.dinode.addrs[bn]; + if (addr == 0 and for_write) { + addr = balloc.alloc(); + if (addr == 0) return 0; + ip.dinode.addrs[bn] = addr; + // Caller will iupdate after the write to flush. + } + return addr; + } + + const ix = bn - layout.NDIRECT; + if (ix >= layout.NINDIRECT) kprintf.panic("bmap: out of range bn={d}", .{bn}); + + var ind = ip.dinode.addrs[layout.NDIRECT]; + if (ind == 0) { + if (!for_write) return 0; + ind = balloc.alloc(); + if (ind == 0) return 0; + ip.dinode.addrs[layout.NDIRECT] = ind; + // Zero the new indirect block (alloc returns a possibly-stale block). + const buf = bufcache.bread(ind); + @memset(&buf.data, 0); + bufcache.bwrite(buf); + bufcache.brelse(buf); + } + + const buf = bufcache.bread(ind); + const slots: [*]u32 = @ptrCast(@alignCast(&buf.data[0])); + var addr = slots[ix]; + if (addr == 0 and for_write) { + addr = balloc.alloc(); + if (addr == 0) { + bufcache.brelse(buf); + return 0; + } + slots[ix] = addr; + bufcache.bwrite(buf); + } + bufcache.brelse(buf); + return addr; + } + ``` + **Existing 3.D callers** of `bmap(ip, bn)` (the read path in `readi`) need updating to `bmap(ip, bn, false)`. + 3. `pub fn writei(ip: *InMemInode, src: [*]const u8, off: u32, n: u32) i32` — write `n` bytes from `src` at offset `off`: + ```zig + pub fn writei(ip: *InMemInode, src: [*]const u8, off: u32, n: u32) i32 { + if (off + n > layout.MAX_FILE_BLOCKS * layout.BLOCK_SIZE) return -1; + + var written: u32 = 0; + while (written < n) { + const cur_off = off + written; + const bn = cur_off / layout.BLOCK_SIZE; + const within = cur_off % layout.BLOCK_SIZE; + const remain_block = layout.BLOCK_SIZE - within; + const remain_total = n - written; + const chunk = if (remain_block < remain_total) remain_block else remain_total; + + const blk = bmap(ip, bn, true); + if (blk == 0) return @intCast(written); // out of disk + + const buf = bufcache.bread(blk); + var i: u32 = 0; + while (i < chunk) : (i += 1) { + buf.data[within + i] = src[written + i]; + } + bufcache.bwrite(buf); + bufcache.brelse(buf); + written += chunk; + } + + if (off + written > ip.dinode.size) { + ip.dinode.size = off + written; + } + iupdate(ip); + return @intCast(written); + } + ``` + 4. `pub fn ialloc(itype: layout.FileType) ?*InMemInode` — find a free disk inode, claim it, return the in-mem cache entry: + ```zig + pub fn ialloc(itype: layout.FileType) ?*InMemInode { + var inum: u32 = 1; + while (inum < layout.NINODES) : (inum += 1) { + const blk = layout.INODE_START_BLK + inum / layout.INODES_PER_BLOCK; + const slot = inum % layout.INODES_PER_BLOCK; + const buf = bufcache.bread(blk); + const inodes: [*]layout.DiskInode = @ptrCast(@alignCast(&buf.data[0])); + if (inodes[slot].type == .Free) { + // Claim it on disk. + inodes[slot] = .{ + .type = itype, + .nlink = 1, + .size = 0, + .addrs = std.mem.zeroes([layout.NDIRECT + 1]u32), + ._reserved = std.mem.zeroes([4]u8), + }; + bufcache.bwrite(buf); + bufcache.brelse(buf); + // Now iget — it loads the dinode we just wrote. + const ip = iget(inum); + ilock(ip); + // ip.dinode now reflects what we wrote. + iunlock(ip); + return ip; + } + bufcache.brelse(buf); + } + return null; + } + ``` + `inum` starts at 1 because inum 0 is the "no inode" sentinel and inum 1 is the root (skipped by the type check — root is `.Dir`, not `.Free`). + 5. `iput` (MODIFIED) — add the on-zero on-disk truncate path: + ```zig + pub fn iput(ip: *InMemInode) void { + if (ip.refs == 1 and ip.valid and ip.dinode.nlink == 0) { + // Last in-memory ref AND no on-disk links: truncate + free. + itrunc(ip); + ip.dinode.type = .Free; + iupdate(ip); + ip.valid = false; + } + if (ip.refs > 0) ip.refs -= 1; + } + ``` + 6. `pub fn itrunc(ip: *InMemInode) void` (NEW, called by `iput`-on-unlink and by `O_TRUNC`): + ```zig + pub fn itrunc(ip: *InMemInode) void { + var i: u32 = 0; + while (i < layout.NDIRECT) : (i += 1) { + if (ip.dinode.addrs[i] != 0) { + balloc.free(ip.dinode.addrs[i]); + ip.dinode.addrs[i] = 0; + } + } + if (ip.dinode.addrs[layout.NDIRECT] != 0) { + const buf = bufcache.bread(ip.dinode.addrs[layout.NDIRECT]); + const slots: [*]const u32 = @ptrCast(@alignCast(&buf.data[0])); + var j: u32 = 0; + while (j < layout.NINDIRECT) : (j += 1) { + if (slots[j] != 0) balloc.free(slots[j]); + } + bufcache.brelse(buf); + balloc.free(ip.dinode.addrs[layout.NDIRECT]); + ip.dinode.addrs[layout.NDIRECT] = 0; + } + ip.dinode.size = 0; + iupdate(ip); + } + ``` + +- **`fs/dir.zig` extensions (MODIFIED)** — `dirlink` real impl + `dirunlink`: + ```zig + pub fn dirlink(dir: *InMemInode, name: []const u8, inum: u16) bool { + if (name.len == 0 or name.len >= layout.DIR_NAME_LEN) return false; + + // Scan for an existing entry with same name (dup) — fail if found. + var off: u32 = 0; + var de: layout.DirEntry = undefined; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) return false; + if (de.inum != 0 and nameEq(de.name[0..], name)) return false; + } + + // Find first free slot OR append at end. + off = 0; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + _ = readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (de.inum == 0) break; + } + // (off == dir.dinode.size if no free slot; writei will extend the file.) + + var entry: layout.DirEntry = .{ .inum = inum, .name = std.mem.zeroes([layout.DIR_NAME_LEN]u8) }; + var i: u32 = 0; + while (i < name.len) : (i += 1) entry.name[i] = name[i]; + // Remaining bytes already zero from std.mem.zeroes. + + const wrote = writei(dir, @ptrCast(&entry), off, @sizeOf(layout.DirEntry)); + return wrote == @sizeOf(layout.DirEntry); + } + + pub fn dirunlink(dir: *InMemInode, name: []const u8) bool { + var off: u32 = 0; + var de: layout.DirEntry = undefined; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) return false; + if (de.inum != 0 and nameEq(de.name[0..], name)) { + // Zero the slot in-place. + de.inum = 0; + de.name = std.mem.zeroes([layout.DIR_NAME_LEN]u8); + _ = writei(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + return true; + } + } + return false; + } + + fn nameEq(slot_name: []const u8, target: []const u8) bool { + // slot_name is fixed-len, NUL-padded; target is exact-len. + if (target.len >= slot_name.len) return false; + var i: u32 = 0; + while (i < target.len) : (i += 1) { + if (slot_name[i] != target[i]) return false; + } + return slot_name[target.len] == 0; + } + ``` + `nameEq` was already needed by 3.D's `dirlookup` — extract it as a file-private helper here and have `dirlookup` use it too (cosmetic refactor). + +- **`fs/fsops.zig` (NEW)** — Glue between syscalls and FS: + ```zig + // src/kernel/fs/fsops.zig — Phase 3.E create + unlink glue. + + const std = @import("std"); + const layout = @import("layout.zig"); + const inode = @import("inode.zig"); + const path_mod = @import("path.zig"); + const dir = @import("dir.zig"); + + /// Create or open-existing-as-create-target. Returns: + /// - existing inode if path resolves and `itype == .File` (idempotent for files); + /// - null if path resolves and `itype == .Dir` (already exists) OR if creation fails. + pub fn create(path: []const u8, itype: layout.FileType) ?*inode.InMemInode { + var leaf: [layout.DIR_NAME_LEN]u8 = undefined; + const parent = path_mod.nameiparent(path, &leaf) orelse return null; + + const leaf_slice = leafSlice(&leaf); + if (leaf_slice.len == 0) { + inode.iput(parent); + return null; + } + + inode.ilock(parent); + // If a child with that name exists already: + if (dir.dirlookup(parent, leaf_slice)) |existing_inum| { + inode.iunlock(parent); + inode.iput(parent); + const existing_ip = inode.iget(existing_inum); + inode.ilock(existing_ip); + if (itype == .File and existing_ip.dinode.type == .File) { + inode.iunlock(existing_ip); + return existing_ip; // idempotent open-or-create + } + inode.iunlock(existing_ip); + inode.iput(existing_ip); + return null; // can't recreate dir; can't recreate non-file as file + } + + const new_ip = inode.ialloc(itype) orelse { + inode.iunlock(parent); + inode.iput(parent); + return null; + }; + + // For directories: pre-add . and .. + if (itype == .Dir) { + inode.ilock(new_ip); + if (!dir.dirlink(new_ip, ".", @intCast(new_ip.inum)) or + !dir.dirlink(new_ip, "..", @intCast(parent.inum))) + { + inode.iunlock(new_ip); + inode.iput(new_ip); + inode.iunlock(parent); + inode.iput(parent); + return null; + } + // Bump parent's nlink for the new ".." pointing at it. + parent.dinode.nlink += 1; + inode.iupdate(parent); + inode.iunlock(new_ip); + } + + if (!dir.dirlink(parent, leaf_slice, @intCast(new_ip.inum))) { + inode.iunlock(parent); + inode.iput(parent); + inode.iput(new_ip); + return null; + } + inode.iunlock(parent); + inode.iput(parent); + return new_ip; + } + + pub fn unlink(path: []const u8) i32 { + var leaf: [layout.DIR_NAME_LEN]u8 = undefined; + const parent = path_mod.nameiparent(path, &leaf) orelse return -1; + + const leaf_slice = leafSlice(&leaf); + if (leaf_slice.len == 0 or + (leaf_slice.len == 1 and leaf_slice[0] == '.') or + (leaf_slice.len == 2 and leaf_slice[0] == '.' and leaf_slice[1] == '.')) + { + inode.iput(parent); + return -1; // can't unlink . or .. + } + + inode.ilock(parent); + const target_inum = dir.dirlookup(parent, leaf_slice) orelse { + inode.iunlock(parent); + inode.iput(parent); + return -1; + }; + + const target_ip = inode.iget(target_inum); + inode.ilock(target_ip); + + // For directories: refuse if non-empty (only . and .. allowed). + if (target_ip.dinode.type == .Dir and !isDirEmpty(target_ip)) { + inode.iunlock(target_ip); + inode.iput(target_ip); + inode.iunlock(parent); + inode.iput(parent); + return -1; + } + + _ = dir.dirunlink(parent, leaf_slice); + + // For directories: also drop parent's nlink (which we bumped on mkdir). + if (target_ip.dinode.type == .Dir) { + parent.dinode.nlink -= 1; + inode.iupdate(parent); + } + + target_ip.dinode.nlink -= 1; + inode.iupdate(target_ip); + inode.iunlock(target_ip); + inode.iput(target_ip); // triggers truncate if nlink == 0 and last ref + + inode.iunlock(parent); + inode.iput(parent); + return 0; + } + + fn leafSlice(leaf: *const [layout.DIR_NAME_LEN]u8) []const u8 { + var n: u32 = 0; + while (n < leaf.len and leaf[n] != 0) : (n += 1) {} + return leaf[0..n]; + } + + fn isDirEmpty(d: *inode.InMemInode) bool { + var off: u32 = 2 * @sizeOf(layout.DirEntry); // skip . and .. + var de: layout.DirEntry = undefined; + while (off < d.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(d, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) break; + if (de.inum != 0) return false; + } + return true; + } + ``` + +- **Userland stdlib (NEW: `src/kernel/user/lib/`)** — Four files. The `_start` parses argc/argv from the System-V tail laid down by `proc.exec` (Plan 3.C): + ``` + sp+0: argc (u32) + sp+4: argv[0] (u32 — pointer to first arg string) + sp+8: argv[1] (u32) + ... + sp+4+4*argc: 0 (NULL terminator) + sp+...: argv strings (NUL-terminated) + ``` + - `start.S`: + ```asm + .section .text._start + .globl _start + _start: + lw a0, 0(sp) # a0 = argc + addi a1, sp, 4 # a1 = &argv[0] + call main # main(argc, argv) → a0 = exit status + li a7, 93 # SYS_exit + ecall + 1: j 1b # never returns + ``` + - `usys.S` — one stub per syscall the userland needs: + ```asm + #define SYSCALL(name, num) \ + .globl name; \ + name: \ + li a7, num; \ + ecall; \ + ret + + SYSCALL(getcwd, 17) + SYSCALL(mkdirat, 34) + SYSCALL(unlinkat, 35) + SYSCALL(chdir, 49) + SYSCALL(openat, 56) + SYSCALL(close, 57) + SYSCALL(lseek, 62) + SYSCALL(read, 63) + SYSCALL(write, 64) + SYSCALL(fstat, 80) + SYSCALL(exit, 93) + SYSCALL(yield, 124) + SYSCALL(getpid, 172) + SYSCALL(sbrk, 214) + SYSCALL(fork, 220) + SYSCALL(exec, 221) + SYSCALL(wait, 260) + SYSCALL(set_fg_pid, 5000) + SYSCALL(console_set_mode, 5001) + ``` + - `ulib.zig`: + ```zig + // src/kernel/user/lib/ulib.zig — userspace standard library. + + pub fn strlen(s: [*:0]const u8) u32 { + var n: u32 = 0; + while (s[n] != 0) : (n += 1) {} + return n; + } + + pub fn strcmp(a: [*:0]const u8, b: [*:0]const u8) i32 { + var i: u32 = 0; + while (a[i] != 0 and b[i] != 0 and a[i] == b[i]) : (i += 1) {} + return @as(i32, a[i]) - @as(i32, b[i]); + } + + pub fn strncmp(a: [*]const u8, b: [*]const u8, n: u32) i32 { + var i: u32 = 0; + while (i < n) : (i += 1) { + if (a[i] != b[i]) return @as(i32, a[i]) - @as(i32, b[i]); + } + return 0; + } + + pub fn memmove(dst: [*]u8, src: [*]const u8, n: u32) void { + if (@intFromPtr(dst) < @intFromPtr(src)) { + var i: u32 = 0; + while (i < n) : (i += 1) dst[i] = src[i]; + } else { + var i: u32 = n; + while (i > 0) { + i -= 1; + dst[i] = src[i]; + } + } + } + + pub fn memset(dst: [*]u8, c: u8, n: u32) void { + var i: u32 = 0; + while (i < n) : (i += 1) dst[i] = c; + } + + pub fn memcmp(a: [*]const u8, b: [*]const u8, n: u32) i32 { + var i: u32 = 0; + while (i < n) : (i += 1) { + if (a[i] != b[i]) return @as(i32, a[i]) - @as(i32, b[i]); + } + return 0; + } + + pub fn atoi(s: [*:0]const u8) i32 { + var i: u32 = 0; + var sign: i32 = 1; + if (s[0] == '-') { sign = -1; i = 1; } + var n: i32 = 0; + while (s[i] >= '0' and s[i] <= '9') : (i += 1) { + n = n * 10 + @as(i32, s[i] - '0'); + } + return sign * n; + } + + /// Read a line from `fd` into `buf`. Returns bytes read (incl. trailing + /// `\n` if present), or 0 on EOF, or -1 on error. + pub fn getline(fd: u32, buf: [*]u8, max: u32) i32 { + var n: u32 = 0; + while (n < max) { + const got = read(fd, buf + n, 1); + if (got <= 0) return if (n == 0) got else @intCast(n); + const c = buf[n]; + n += 1; + if (c == '\n') return @intCast(n); + } + return @intCast(n); + } + + // Forward decls for usys.S stubs. + pub extern fn read(fd: u32, buf: [*]u8, n: u32) i32; + pub extern fn write(fd: u32, buf: [*]const u8, n: u32) i32; + pub extern fn close(fd: u32) i32; + pub extern fn openat(dirfd: u32, path: [*:0]const u8, flags: u32) i32; + pub extern fn lseek(fd: u32, off: i32, whence: u32) i32; + pub extern fn fstat(fd: u32, st: *anyopaque) i32; + pub extern fn mkdirat(dirfd: u32, path: [*:0]const u8) i32; + pub extern fn unlinkat(dirfd: u32, path: [*:0]const u8, flags: u32) i32; + pub extern fn chdir(path: [*:0]const u8) i32; + pub extern fn getcwd(buf: [*]u8, sz: u32) i32; + pub extern fn fork() i32; + pub extern fn exec(path: [*:0]const u8, argv: [*]const ?[*:0]const u8) i32; + pub extern fn wait(status: ?*i32) i32; + pub extern fn exit(status: i32) noreturn; + pub extern fn getpid() u32; + pub extern fn yield() u32; + pub extern fn sbrk(incr: i32) i32; + pub extern fn set_fg_pid(pid: u32) u32; + pub extern fn console_set_mode(mode: u32) u32; + + // Stat layout — must match kernel file.zig::Stat. + pub const Stat = extern struct { + type: u32, + size: u32, + }; + + pub const STAT_FILE: u32 = 1; + pub const STAT_DIR: u32 = 2; + + pub const O_RDONLY: u32 = 0x000; + pub const O_WRONLY: u32 = 0x001; + pub const O_RDWR: u32 = 0x002; + pub const O_CREAT: u32 = 0x040; + pub const O_TRUNC: u32 = 0x200; + pub const O_APPEND: u32 = 0x400; + ``` + - `uprintf.zig`: + ```zig + // src/kernel/user/lib/uprintf.zig — minimal printf for fd. + + const ulib = @import("ulib.zig"); + + fn putc(fd: u32, c: u8) void { + var b: [1]u8 = .{c}; + _ = ulib.write(fd, &b, 1); + } + + fn putStr(fd: u32, s: [*:0]const u8) void { + var i: u32 = 0; + while (s[i] != 0) : (i += 1) putc(fd, s[i]); + } + + fn putUint(fd: u32, n: u32, base: u32) void { + var buf: [16]u8 = undefined; + var i: u32 = 0; + var v = n; + if (v == 0) { + putc(fd, '0'); + return; + } + while (v > 0) { + const d = v % base; + buf[i] = if (d < 10) @intCast('0' + d) else @intCast('a' + d - 10); + i += 1; + v /= base; + } + while (i > 0) { + i -= 1; + putc(fd, buf[i]); + } + } + + fn putInt(fd: u32, n: i32, base: u32) void { + if (n < 0) { + putc(fd, '-'); + putUint(fd, @intCast(-n), base); + } else { + putUint(fd, @intCast(n), base); + } + } + + pub const Arg = union(enum) { + i: i32, + u: u32, + s: [*:0]const u8, + c: u8, + }; + + pub fn printf(fd: u32, fmt: [*:0]const u8, args: []const Arg) void { + var i: u32 = 0; + var ai: u32 = 0; + while (fmt[i] != 0) : (i += 1) { + if (fmt[i] != '%') { + putc(fd, fmt[i]); + continue; + } + i += 1; + if (fmt[i] == 0) return; + switch (fmt[i]) { + 'd' => { putInt(fd, args[ai].i, 10); ai += 1; }, + 'u' => { putUint(fd, args[ai].u, 10); ai += 1; }, + 'x' => { putUint(fd, args[ai].u, 16); ai += 1; }, + 's' => { putStr(fd, args[ai].s); ai += 1; }, + 'c' => { putc(fd, args[ai].c); ai += 1; }, + '%' => putc(fd, '%'), + else => { + putc(fd, '%'); + putc(fd, fmt[i]); + }, + } + } + } + ``` + +- **`build.zig` `addUserBinary` helper (NEW)** — Each user binary needs the same recipe: compile `start.S`, `usys.S`, `ulib.zig`, `uprintf.zig`, the binary's `main.zig`, link with `user_linker.ld`. The helper takes `(b, name, main_src) → *std.Build.Step.Compile` and exposes the artifact as a build step that the `shell-fs-img` target depends on. + +- **Userland binaries (NEW: `src/kernel/user/{init_shell,sh,ls,cat,echo,mkdir,rm}.zig`)** — see Tasks 25–31 for full source. + +- **`mkfs.zig` updates (MODIFIED)** — Three changes: + 1. Skip directory entries whose name starts with `.` (so `.gitkeep` doesn't get installed). + 2. Recurse into empty subdirectories (create the on-disk `Dir` inode + `.` / `..` entries even if no children). + 3. Add `--init ` flag (default = current behavior, which hard-codes `fs_init.elf` at `/bin/init`); when supplied, install `` at `/bin/init` instead. + +- **`shell-fs-img` build target (NEW)** — Runs mkfs against a new staging dir + the new binaries: + - `src/kernel/userland/shell-fs/etc/motd` — `"hello from phase 3\n"` (same content as 3.D, kept so `cat /etc/motd` works in the milestone). + - `src/kernel/userland/shell-fs/tmp/.gitkeep` — empty file; mkfs sees the parent `tmp/` dir, skips `.gitkeep` because of the leading-dot rule, ends up with empty `/tmp/` in the image. + - mkfs invocation: `mkfs --root src/kernel/userland/shell-fs/ --bin zig-out/userland/bin/ --init zig-out/userland/bin/init_shell.elf --out zig-out/shell-fs.img`. + +- **`tests/e2e/shell.zig` + `tests/e2e/shell_input.txt` + `e2e-shell` build step (NEW)** — Host harness mirroring `tests/e2e/fs.zig`: + - Spawns `ccc --input tests/e2e/shell_input.txt --disk zig-out/shell-fs.img zig-out/bin/kernel-fs.elf`. + - Reads stdout, asserts exit code 0. + - Asserts `std.mem.indexOf` finds each of: `"$ ls /bin"`, `"sh\n"`, `"$ echo hi > /tmp/x"`, `"$ cat /tmp/x"`, `"hi\n"`, `"$ rm /tmp/x"`, `"$ exit"`. + +--- + +## File structure (final state at end of Plan 3.E) + +``` +ccc/ +├── .gitignore ← UNCHANGED (zig-out/ already covers shell-fs.img) +├── .gitmodules ← UNCHANGED +├── build.zig ← MODIFIED (+addUserBinary helper; +kernel-init-shell, kernel-sh, kernel-ls, kernel-cat, kernel-echo, kernel-mkdir, kernel-rm; +shell-fs-img; +e2e-shell + tests/e2e/shell.zig) +├── build.zig.zon ← UNCHANGED +├── README.md ← MODIFIED (status; Phase 3.E note; +e2e-shell row in Building) +├── demo/ ← UNCHANGED +├── programs/ ← UNCHANGED +├── src/ +│ ├── emulator/ ← UNCHANGED (no emulator changes — UART RX FIFO and PLIC already shipped in 3.A) +│ └── kernel/ +│ ├── boot.S ← UNCHANGED +│ ├── linker.ld ← UNCHANGED +│ ├── mtimer.S ← UNCHANGED +│ ├── trampoline.S ← UNCHANGED +│ ├── swtch.S ← UNCHANGED +│ ├── kmain.zig ← MODIFIED (+IRQ_UART_RX enable + console.init + console-fd setup in FS_DEMO arm) +│ ├── kprintf.zig ← UNCHANGED +│ ├── page_alloc.zig ← UNCHANGED +│ ├── plic.zig ← MODIFIED (+IRQ_UART_RX const) +│ ├── block.zig ← UNCHANGED +│ ├── proc.zig ← UNCHANGED (kill, killed flag, ofile, cwd already exist from 3.C+3.D) +│ ├── sched.zig ← UNCHANGED +│ ├── elfload.zig ← UNCHANGED +│ ├── file.zig ← MODIFIED (+Console handling in read; +new write; lseek/fstat console arm) +│ ├── console.zig ← NEW (line discipline; fd 0/1/2 backing) +│ ├── syscall.zig ← MODIFIED (sysWrite via file.write; +sysMkdirat 34; +sysUnlinkat 35; sysOpenat O_CREAT/O_TRUNC/O_APPEND; sysSetFgPid + sysConsoleSetMode wired; killed-flag check in dispatch) +│ ├── trap.zig ← MODIFIED (+IRQ_UART_RX → uart.isr in S-external dispatch) +│ ├── uart.zig ← MODIFIED (+readByte; +isr drains FIFO into console.feedByte) +│ ├── vm.zig ← UNCHANGED +│ ├── mkfs.zig ← MODIFIED (+skip dot-files; +recurse into empty dirs; +--init flag) +│ ├── fs/ +│ │ ├── layout.zig ← UNCHANGED +│ │ ├── bufcache.zig ← UNCHANGED (bwrite already exists from 3.D) +│ │ ├── balloc.zig ← UNCHANGED (alloc/free already exist from 3.D) +│ │ ├── inode.zig ← MODIFIED (+iupdate; +bmap for_write; +writei; +ialloc; +itrunc; iput-on-zero truncate) +│ │ ├── dir.zig ← MODIFIED (+real dirlink; +dirunlink; refactor nameEq) +│ │ ├── path.zig ← UNCHANGED +│ │ └── fsops.zig ← NEW (create + unlink glue) +│ ├── user/ +│ │ ├── userprog.zig ← UNCHANGED +│ │ ├── userprog2.zig ← UNCHANGED +│ │ ├── init.zig ← UNCHANGED (3.C fork-mode init) +│ │ ├── hello.zig ← UNCHANGED +│ │ ├── fs_init.zig ← UNCHANGED (3.D fs-mode init; still installed in fs.img) +│ │ ├── user_linker.ld ← UNCHANGED +│ │ ├── lib/ +│ │ │ ├── start.S ← NEW +│ │ │ ├── usys.S ← NEW +│ │ │ ├── ulib.zig ← NEW +│ │ │ └── uprintf.zig ← NEW +│ │ ├── init_shell.zig ← NEW (loops fork-exec-sh-wait) +│ │ ├── sh.zig ← NEW (~350 LoC) +│ │ ├── ls.zig ← NEW (~70 LoC) +│ │ ├── cat.zig ← NEW (~40 LoC) +│ │ ├── echo.zig ← NEW (~25 LoC) +│ │ ├── mkdir.zig ← NEW (~25 LoC) +│ │ └── rm.zig ← NEW (~25 LoC) +│ └── userland/ +│ ├── fs/ +│ │ └── etc/ +│ │ └── motd ← UNCHANGED (3.D content; used by fs.img) +│ └── shell-fs/ ← NEW (staging dir for shell-fs.img) +│ ├── etc/ +│ │ └── motd ← NEW (same content; symlink would also work but copy avoids cross-FS concerns) +│ └── tmp/ +│ └── .gitkeep ← NEW (so git tracks the empty dir; mkfs skips the dot-file) +└── tests/ + ├── e2e/ + │ ├── kernel.zig ← UNCHANGED + │ ├── multiproc.zig ← UNCHANGED + │ ├── fork.zig ← UNCHANGED + │ ├── fs.zig ← UNCHANGED + │ ├── snake.zig ← UNCHANGED + │ ├── snake_input.txt ← UNCHANGED + │ ├── shell.zig ← NEW (e2e-shell verifier) + │ └── shell_input.txt ← NEW (scripted shell session) + ├── fixtures/ ← UNCHANGED + ├── riscv-tests/ ← UNCHANGED + ├── riscv-tests-p.ld ← UNCHANGED + ├── riscv-tests-s.ld ← UNCHANGED + └── riscv-tests-shim/ ← UNCHANGED +``` + +**Files removed in this plan:** none. + +**Files renamed in this plan:** none. + +--- + +## Conventions used in this plan + +- All Zig code targets Zig 0.16.x. Same API surface as Plans 2.D, 3.A, 3.B, 3.C, 3.D. +- Tests live as inline `test "name" { ... }` blocks alongside the code under test. `zig build test` runs every host-runnable test reachable from `src/emulator/main.zig`. Kernel-side modules (those in `src/kernel/`) are RV32 cross-compiled and **not run as host tests**; we cover them via the e2e harnesses (which exercise the same code under the emulator) and by host-runnable unit tests for any *pure-data* logic that has a host equivalent. 3.E adds host tests for `fs/fsops.zig` (path validation; leaf extraction), `mkfs.zig` (dot-file skip + empty-dir recursion + --init flag), `tests/e2e/shell.zig` (the e2e harness itself, which runs as part of `e2e-shell`). +- Each task ends with a TDD cycle: write failing test, see it fail, implement minimally, verify pass, commit. Commit messages follow Conventional Commits. The commit footer used elsewhere in the repo is preserved unchanged. +- When extending a grouped switch (syscall.zig dispatch arms, build.zig kernel object list), we show the full block so diffs are unambiguous. +- Kernel asm offsets and Zig `pub const`s that name the same byte position must always be paired with a comptime assert tying them together (Phase 2 set this convention; 3.E preserves it — but 3.E does not introduce any new asm-visible offsets, so no new asserts land beyond the existing `@sizeOf(DiskInode) == 64` and `@sizeOf(DirEntry) == 16` from 3.D). +- Whenever a test needs a real `Memory`, it uses a local `setupRig()` helper. Per Plan 2.A/B/3.A convention, we don't extract a shared rig module — each file gets its own copy. +- Task order respects strict dependencies: PLIC IRQ const + UART ISR + trap dispatch + console line discipline before file table extension; file extension before sysWrite/sysRead routing change; FS write building blocks (iupdate, bmap-write, writei, ialloc, itrunc, iput-on-zero, dirlink, dirunlink) before the fsops layer; fsops before sysOpenat O_CREAT / sysMkdirat / sysUnlinkat; user stdlib before any user binary; user binaries before mkfs update; mkfs update before shell-fs-img target; shell-fs-img before e2e-shell. +- All references to "Plan 3.D" mean the implementation plan at `docs/superpowers/plans/2026-04-26-phase3-plan-d-bufcache-block-fs-read.md`. References to "Phase 3 spec" mean `docs/superpowers/specs/2026-04-25-phase3-multi-process-os-design.md`. + +--- + +## Tasks + +### Task 1: Add `IRQ_UART_RX` constant to `plic.zig` + +**Files:** +- Modify: `src/kernel/plic.zig` (add the new constant; existing 3.D API unchanged) + +**Why this task first:** every later task that touches UART RX delivery needs the constant, and adding a constant is a no-op landing — no callers yet, regression-safe. + +- [ ] **Step 1: Add the constant** + +In `src/kernel/plic.zig`, find: + +```zig +pub const IRQ_BLOCK: u32 = 1; +// IRQ_UART_RX = 10 (3.E) +``` + +Replace with: + +```zig +pub const IRQ_BLOCK: u32 = 1; +pub const IRQ_UART_RX: u32 = 10; +``` + +- [ ] **Step 2: Build the kernel to verify the addition compiles** + +Run: `zig build kernel-fs` +Expected: PASS — `plic.zig` compiles; the new constant has no callers yet. + +Run: `zig fmt --check src/kernel/plic.zig` +Expected: PASS (no output). + +- [ ] **Step 3: Run regression e2e suite** + +Run: `zig build e2e-fs` +Expected: PASS. + +Run: `zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/plic.zig +git commit -m "feat(plic): add IRQ_UART_RX = 10 constant for Phase 3.E" +``` + +--- + +### Task 2: Add `console.zig` skeleton (state, init, write, setMode, setFgPid) + +**Files:** +- Create: `src/kernel/console.zig` (state + init + write + setMode + setFgPid; `feedByte` and `read` are Tasks 3 + 4) + +**Why this task here:** the file table (Task 8) and `kmain` (Task 9) both need `console.write`, `console.setMode`, `console.setFgPid`, and `console.init` to exist as symbols before they can compile. Landing the skeleton first keeps the dependency chain clean — `feedByte` and `read` are the next two tasks and complete the module. + +- [ ] **Step 1: Create `src/kernel/console.zig` with skeleton** + +```zig +// src/kernel/console.zig — Phase 3.E console line discipline. +// +// Backing for fd 0/1/2 in every process. Holds a 128-byte circular input +// buffer (xv6-style: `r`, `w`, `e` indices), a `mode` (Cooked vs Raw), +// and an `fg_pid` (the foreground process that ^C kills). +// +// API: +// init(): zero indices; mode = Cooked; fg_pid = 0. +// setMode(mode): 0 = Cooked, anything else = Raw. +// setFgPid(pid): who ^C kills. +// write(src_va, n): SUM-1 copy bytes through uart.writeByte. Returns n. +// feedByte(b): line discipline (Task 3). +// read(dst_va, n): sleep until r != w, copy bytes (Task 4). +// +// Single-hart: all state is global and uninstanced. + +const uart = @import("uart.zig"); + +pub const ConsoleMode = enum(u32) { Cooked = 0, Raw = 1 }; +pub const INPUT_BUF_SIZE: u32 = 128; + +pub var input: struct { + buf: [INPUT_BUF_SIZE]u8 = undefined, + r: u32 = 0, + w: u32 = 0, + e: u32 = 0, +} = .{}; + +pub var mode: ConsoleMode = .Cooked; +pub var fg_pid: u32 = 0; + +pub fn init() void { + input.r = 0; + input.w = 0; + input.e = 0; + mode = .Cooked; + fg_pid = 0; +} + +pub fn setMode(new_mode: u32) void { + mode = if (new_mode == 0) .Cooked else .Raw; +} + +pub fn setFgPid(pid: u32) void { + fg_pid = pid; +} + +const SSTATUS_SUM: u32 = 1 << 18; + +inline fn setSum() void { + asm volatile ("csrs sstatus, %[b]" :: [b] "r" (SSTATUS_SUM) : .{ .memory = true }); +} + +inline fn clearSum() void { + asm volatile ("csrc sstatus, %[b]" :: [b] "r" (SSTATUS_SUM) : .{ .memory = true }); +} + +/// SUM-1 copy `n` bytes from user VA `src_va` to the UART. Returns `n`. +pub fn write(src_va: u32, n: u32) i32 { + setSum(); + defer clearSum(); + var i: u32 = 0; + while (i < n) : (i += 1) { + const p: *const volatile u8 = @ptrFromInt(src_va + i); + uart.writeByte(p.*); + } + return @intCast(n); +} + +// Stubs for Tasks 3 + 4. Return safe defaults so the module compiles +// with no callers exercising them. +pub fn feedByte(b: u8) void { + _ = b; +} + +pub fn read(dst_va: u32, n: u32) i32 { + _ = dst_va; + _ = n; + return 0; +} +``` + +- [ ] **Step 2: Verify the module parses** + +Run: `zig fmt --check src/kernel/console.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS — module is leaf, not yet imported. + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/console.zig +git commit -m "feat(console): add console.zig skeleton (state + init + write + mode/fg_pid)" +``` + +--- + +### Task 3: Add cooked + raw line discipline to `console.feedByte` + +**Files:** +- Modify: `src/kernel/console.zig` (replace the Task 2 `feedByte` stub with the real cooked + raw implementation) + +**Why this task here:** the trap-side `uart.isr` (Task 5) calls `feedByte` for every drained byte. We need the real implementation in place before Task 5 wires the IRQ path, otherwise the kernel would silently drop typed input. + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `src/kernel/console.zig`, replace the body of `pub fn feedByte` with: + +```zig +pub fn feedByte(b: u8) void { + if (mode == .Raw) { + // Raw: append, wake, no echo, no special handling. + if (input.e -% input.r >= INPUT_BUF_SIZE) return; // buf full — drop + input.buf[input.e % INPUT_BUF_SIZE] = b; + input.e += 1; + input.w = input.e; + proc.wakeup(@intFromPtr(&input.r)); + return; + } + + // Cooked. + switch (b) { + 0x03 => { // ^C + // Erase any in-progress line (between w and e). + while (input.e != input.w) : (input.e -%= 1) { + uart.writeByte(0x08); + uart.writeByte(' '); + uart.writeByte(0x08); + } + uart.writeByte('^'); + uart.writeByte('C'); + uart.writeByte('\n'); + // Discard any committed-but-not-yet-read bytes too — clean slate. + input.r = input.w; + // Kill foreground. + if (fg_pid != 0) _ = proc.kill(fg_pid); + proc.wakeup(@intFromPtr(&input.r)); + }, + 0x15 => { // ^U — kill current line + while (input.e != input.w) : (input.e -%= 1) { + uart.writeByte(0x08); + uart.writeByte(' '); + uart.writeByte(0x08); + } + }, + 0x08, 0x7F => { // backspace / DEL + if (input.e != input.w) { + input.e -%= 1; + uart.writeByte(0x08); + uart.writeByte(' '); + uart.writeByte(0x08); + } + }, + 0x04 => { // ^D EOF + // Commit whatever's typed; reader will see r == w after consuming. + input.w = input.e; + proc.wakeup(@intFromPtr(&input.r)); + }, + else => { + const c: u8 = if (b == '\r') '\n' else b; + // Drop unprintable control bytes other than \n. + if (c != '\n' and (c < 0x20 or c == 0x7F)) return; + // Drop if buf is full. + if (input.e -% input.r >= INPUT_BUF_SIZE) return; + input.buf[input.e % INPUT_BUF_SIZE] = c; + input.e += 1; + uart.writeByte(c); + if (c == '\n') { + input.w = input.e; + proc.wakeup(@intFromPtr(&input.r)); + } + }, + } +} +``` + +- [ ] **Step 2: Add the `proc` import at the top** + +In `src/kernel/console.zig`, add to the imports: + +```zig +const proc = @import("proc.zig"); +``` + +- [ ] **Step 3: Verify** + +Run: `zig fmt --check src/kernel/console.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS — still no callers, still compiles. + +- [ ] **Step 4: Regression e2e** + +Run: `zig build e2e-fs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/kernel/console.zig +git commit -m "feat(console): implement cooked + raw line discipline in feedByte" +``` + +--- + +### Task 4: Implement `console.read` (sleep on `&input.r`, SUM-1 copy) + +**Files:** +- Modify: `src/kernel/console.zig` (replace the Task 2 `read` stub with the real sleep-and-copy loop) + +**Why this task here:** `file.read` (Task 8) dispatches Console fds to `console.read`. The function must exist and behave correctly before any Console-fd `read` syscall can land. + +- [ ] **Step 1: Replace the stub with the real implementation** + +In `src/kernel/console.zig`, replace the body of `pub fn read` with: + +```zig +pub fn read(dst_va: u32, n: u32) i32 { + var got: u32 = 0; + while (got < n) { + // Wait for at least one byte to be deliverable. + while (input.r == input.w) { + if (proc.cur().killed != 0) return -1; + proc.sleep(@intFromPtr(&input.r)); + } + const c = input.buf[input.r % INPUT_BUF_SIZE]; + input.r += 1; + + // ^D in the buffer: an EOF marker. Consume but don't deliver. + if (c == 0x04) { + // If we already delivered something, return it; else 0 = EOF. + break; + } + + setSum(); + const dst: *volatile u8 = @ptrFromInt(dst_va + got); + dst.* = c; + clearSum(); + got += 1; + + if (c == '\n') break; + } + return @intCast(got); +} +``` + +- [ ] **Step 2: Verify** + +Run: `zig fmt --check src/kernel/console.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/console.zig +git commit -m "feat(console): implement read() with sleep-on-input + SUM-1 copy" +``` + +--- + +### Task 5: Extend `uart.zig` with `readByte` + `isr` + +**Files:** +- Modify: `src/kernel/uart.zig` (add MMIO read addresses, `readByte`, `isr` that drains FIFO into console) + +**Why this task here:** Task 6 wires `uart.isr` into `trap.zig`. The function must exist as a symbol before that wiring compiles. Drain semantics matter: `isr` MUST loop until the FIFO is empty (level-triggered IRQ) or the kernel re-enters immediately. + +- [ ] **Step 1: Add `console` import + new MMIO constants + `readByte` + `isr`** + +The current `src/kernel/uart.zig` is short. Replace its contents with: + +```zig +// src/kernel/uart.zig — kernel-side NS16550A UART helper. +// +// Phase 3.E adds the RX path. The emulator's uart.zig forwards THR +// stores straight to stdout (writeByte), and now also fills an RX FIFO +// from --input or stdin. PLIC source 10 fires whenever the FIFO is +// non-empty (level-triggered). + +const console = @import("console.zig"); + +pub const UART_BASE: u32 = 0x1000_0000; +pub const UART_THR: u32 = UART_BASE + 0x0; // transmit hold (write) +pub const UART_RBR: u32 = UART_BASE + 0x0; // receive buffer (read) +pub const UART_LSR: u32 = UART_BASE + 0x5; // line status +pub const LSR_THRE: u8 = 1 << 5; // transmitter empty +pub const LSR_DR: u8 = 1 << 0; // data ready + +pub fn writeByte(b: u8) void { + const lsr: *const volatile u8 = @ptrFromInt(UART_LSR); + while ((lsr.* & LSR_THRE) == 0) {} + const thr: *volatile u8 = @ptrFromInt(UART_THR); + thr.* = b; +} + +pub fn writeBytes(s: []const u8) void { + for (s) |b| writeByte(b); +} + +/// Read one byte from the RX FIFO. Returns null if the FIFO is empty. +pub fn readByte() ?u8 { + const lsr: *const volatile u8 = @ptrFromInt(UART_LSR); + if ((lsr.* & LSR_DR) == 0) return null; + const rbr: *const volatile u8 = @ptrFromInt(UART_RBR); + return rbr.*; +} + +/// PLIC src 10 ISR. Drain the FIFO into the console line discipline. +/// MUST loop until the FIFO is empty — IRQ is level-triggered, so any +/// remaining bytes would re-enter us immediately. +pub fn isr() void { + while (readByte()) |b| { + console.feedByte(b); + } +} +``` + +- [ ] **Step 2: Verify** + +Run: `zig fmt --check src/kernel/uart.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS — `uart.isr` is a leaf for now (Task 6 wires it). + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — writeByte semantics unchanged (we kept the same THRE poll). + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/uart.zig +git commit -m "feat(uart): add RX MMIO + readByte + isr (drains FIFO into console)" +``` + +--- + +### Task 6: Wire `trap.zig` S-external dispatch for IRQ #10 (UART RX) + +**Files:** +- Modify: `src/kernel/trap.zig` (extend the two `switch (irq)` arms — one in `m_trap_dispatch_s_forwarded`, one in `s_trap_dispatch` — to dispatch IRQ #10 to `uart.isr`) + +**Why this task here:** until this lands, IRQ #10 fires forever (level-triggered) and panics with "unknown PLIC src". We need this BEFORE Task 7 enables the IRQ in `kmain`, otherwise the first byte of `--input` panics the kernel. + +- [ ] **Step 1: Add the `uart` import (if not already present)** + +In `src/kernel/trap.zig`, near the existing `const block = @import("block.zig");`, add: + +```zig +const uart = @import("uart.zig"); +``` + +(If the import already exists, leave it alone.) + +- [ ] **Step 2: Extend the M-mode forwarded dispatch arm** + +Find: + +```zig +if (is_interrupt and cause == 9) { + const irq = plic.claim(); + switch (irq) { + plic.IRQ_BLOCK => block.isr(), + else => kprintf.panic("unknown PLIC src: {d}", .{irq}), + } + plic.complete(irq); + return; +} +``` + +…and in BOTH places (one in `m_trap_dispatch_s_forwarded` near line ~150, one in `s_trap_dispatch` near line ~220), change the switch to: + +```zig +if (is_interrupt and cause == 9) { + const irq = plic.claim(); + switch (irq) { + plic.IRQ_BLOCK => block.isr(), + plic.IRQ_UART_RX => uart.isr(), + else => kprintf.panic("unknown PLIC src: {d}", .{irq}), + } + plic.complete(irq); + return; +} +``` + +- [ ] **Step 3: Verify** + +Run: `zig fmt --check src/kernel/trap.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 4: Regression e2e (IRQ #10 still disabled — should be no behavior change)** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — `uart.isr` is unreachable until Task 7 enables the IRQ. + +- [ ] **Step 5: Commit** + +```bash +git add src/kernel/trap.zig +git commit -m "feat(trap): dispatch PLIC IRQ #10 (UART RX) to uart.isr" +``` + +--- + +### Task 7: Wire `sysSetFgPid` (5000), `sysConsoleSetMode` (5001), and killed-flag check + +**Files:** +- Modify: `src/kernel/syscall.zig` (replace the two stubs with real impls; append killed-flag check at the bottom of `dispatch`) + +**Why this task here:** `set_fg_pid` and `console_set_mode` are syscalls that userland will call once the shell exists (Tasks 25–31). The killed-flag check makes `^C` actually kill foreground programs that were sleeping in a syscall. Both are tiny additions; landing them now keeps Group A self-contained before we move into FS work. + +- [ ] **Step 1: Add the `console` import at the top of `syscall.zig`** + +Find: + +```zig +const file = @import("file.zig"); +``` + +Add immediately after: + +```zig +const console = @import("console.zig"); +``` + +- [ ] **Step 2: Replace `sysSetFgPid` and `sysConsoleSetMode` bodies** + +Find (~line 287): + +```zig +fn sysSetFgPid(pid: u32) u32 { + _ = pid; + return 0; +} +``` + +Replace with: + +```zig +fn sysSetFgPid(pid: u32) u32 { + console.setFgPid(pid); + return 0; +} +``` + +Find (~line 295): + +```zig +fn sysConsoleSetMode(mode: u32) u32 { + _ = mode; + return 0; +} +``` + +Replace with: + +```zig +fn sysConsoleSetMode(mode: u32) u32 { + console.setMode(mode); + return 0; +} +``` + +- [ ] **Step 3: Append killed-flag check to `dispatch`** + +Find the end of `pub fn dispatch(tf: *trap.TrapFrame) void { switch (tf.a7) { ... } }` (last line is the closing `}`). Insert a killed-flag check between the switch's closing `}` and the function's closing `}`: + +```zig +pub fn dispatch(tf: *trap.TrapFrame) void { + switch (tf.a7) { + // ... existing arms unchanged ... + 5000 => tf.a0 = sysSetFgPid(tf.a0), + 5001 => tf.a0 = sysConsoleSetMode(tf.a0), + else => tf.a0 = @bitCast(@as(i32, -38)), // -ENOSYS + } + + // Phase 3.E: if the process was killed (e.g. by ^C while sleeping + // in this syscall), exit on the way back to user instead of returning. + if (proc.cur().killed != 0) { + proc.exit(-1); + } +} +``` + +(If your linter complains about `proc.exit` being noreturn after the `if`, that's fine — Zig treats noreturn-from-true-branch as expected.) + +- [ ] **Step 4: Verify** + +Run: `zig fmt --check src/kernel/syscall.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 5: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — neither test path triggers `^C`, so the killed check is dead code in the regression path. + +- [ ] **Step 6: Commit** + +```bash +git add src/kernel/syscall.zig +git commit -m "feat(syscall): wire sysSetFgPid/sysConsoleSetMode + killed-flag check" +``` + +--- + +### Task 8: Extend `file.zig` with Console-aware `read` + new `write` + +**Files:** +- Modify: `src/kernel/file.zig` (Console arms in `read`, `lseek`, `fstat`; new `pub fn write` with Console + Inode dispatch) + +**Why this task here:** the syscall layer change in Task 10 routes `sysRead` and `sysWrite` through `file.read` / `file.write`. Both must handle Console fds before that routing lands. + +- [ ] **Step 1: Add `console` import** + +In `src/kernel/file.zig`, find: + +```zig +const inode = @import("fs/inode.zig"); +const proc = @import("proc.zig"); +const layout = @import("fs/layout.zig"); +``` + +Add: + +```zig +const console = @import("console.zig"); +``` + +- [ ] **Step 2: Add Console arm to `read`** + +Find the `pub fn read` function. Before the existing `if (f.type != .Inode or f.ip == null) return -1;` line, add a Console arm: + +```zig +pub fn read(idx: u32, dst_user_va: u32, n: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + + if (f.type == .Console) { + return console.read(dst_user_va, n); + } + + if (f.type != .Inode or f.ip == null) return -1; + // ... existing Inode path unchanged ... +``` + +Keep the existing inode body unchanged below the Console arm. + +- [ ] **Step 3: Add new `pub fn write`** + +After `pub fn read(...)` and before `pub fn lseek(...)`, insert: + +```zig +// Static staging buffer for file.write inode path. Same justification +// as read_kbuf: 4 KB on the kernel stack would blow the per-process +// kernel page; single-hart kernel makes a global buffer safe. +var write_kbuf: [4096]u8 align(4) = undefined; + +/// Write up to `n` bytes from user VA `src_user_va` to file `idx`. +/// Returns bytes written (≥ 0) or -1 on bad fd. +pub fn write(idx: u32, src_user_va: u32, n: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + + if (f.type == .Console) { + return console.write(src_user_va, n); + } + + if (f.type != .Inode or f.ip == null) return -1; + + const want = if (n > write_kbuf.len) write_kbuf.len else n; + + // SUM-1 copy from user into kernel staging buffer. + setSum(); + var i: u32 = 0; + while (i < want) : (i += 1) { + const src_p: *const volatile u8 = @ptrFromInt(src_user_va + i); + write_kbuf[i] = src_p.*; + } + clearSum(); + + inode.ilock(f.ip.?); + const wrote = inode.writei(f.ip.?, &write_kbuf, f.off, @intCast(want)); + inode.iunlock(f.ip.?); + + if (wrote > 0) f.off += @intCast(wrote); + return wrote; +} +``` + +- [ ] **Step 4: Update `lseek` Console arm** + +Find: + +```zig +pub fn lseek(idx: u32, off: i32, whence: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + if (f.type != .Inode or f.ip == null) return -1; +``` + +Insert a Console reject arm before the type check: + +```zig +pub fn lseek(idx: u32, off: i32, whence: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + if (f.type == .Console) return -1; // not seekable + if (f.type != .Inode or f.ip == null) return -1; +``` + +- [ ] **Step 5: Update `fstat` Console arm** + +Find: + +```zig +pub fn fstat(idx: u32, stat_user_va: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + if (f.type != .Inode or f.ip == null) return -1; +``` + +Insert a Console arm before the type check: + +```zig +pub fn fstat(idx: u32, stat_user_va: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + if (f.type == .Console) { + const stat: Stat = .{ .type = @intFromEnum(layout.FileType.File), .size = 0 }; + setSum(); + const dst: *volatile Stat = @ptrFromInt(stat_user_va); + dst.* = stat; + clearSum(); + return 0; + } + if (f.type != .Inode or f.ip == null) return -1; +``` + +(We report Console as `FileType.File` size 0 — `ls` will show fd 0/1/2 as zero-length files if it ever stats them, which it doesn't in 3.E but the value is sane.) + +- [ ] **Step 6: Verify** + +Run: `zig fmt --check src/kernel/file.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS — `inode.writei` is the only undefined symbol; **expected to fail at link time**. If it does: + +``` +error: unresolved external symbol `inode.writei` +``` + +That's expected — Task 11 adds `writei`. Skip Step 7 if the link fails on writei; loop back after Task 11. + +If you'd rather land Task 8 cleanly first, defer the `write` function and stub it as: + +```zig +pub fn write(idx: u32, src_user_va: u32, n: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + if (f.type == .Console) return console.write(src_user_va, n); + _ = src_user_va; _ = n; + return -1; // Inode path: stub until Task 11 lands writei +} +``` + +Then come back after Task 11 and replace the stub. **Recommended:** stub it; it lets every later task verify in isolation. + +- [ ] **Step 7: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — `read` Console arm is unreachable (no Console fd allocated yet); `write` is a no-op for Inode and a passthrough for Console. + +- [ ] **Step 8: Commit** + +```bash +git add src/kernel/file.zig +git commit -m "feat(file): add Console handling to read/lseek/fstat + new write (Inode stub)" +``` + +--- + +### Task 9: Wire console fd 0/1/2 setup in `kmain` FS_DEMO arm + +**Files:** +- Modify: `src/kernel/kmain.zig` (in the FS_DEMO arm, after PID 1 alloc and before `proc.exec`, allocate a Console File entry and install it as fds 0/1/2) + +**Why this task here:** without this, `init` boots with `ofile = .{0} ** NOFILE` (no fds) and the first `write(1, ...)` call fails with `-EBADF` once Task 10 routes through `file.write`. We can't land Task 10 until init has its console fds. + +- [ ] **Step 1: Add `file` and `console` imports if not present** + +In `src/kernel/kmain.zig`, near the existing `const inode = @import("fs/inode.zig");`, add: + +```zig +const file = @import("file.zig"); +const console = @import("console.zig"); +``` + +- [ ] **Step 2: Initialize file table + console + install fds** + +Find the FS_DEMO arm. Look for: + +```zig +init_p.cwd = 0; // lazy-root +``` + +Immediately after that line and BEFORE the existing `const sscratch_val_fs: u32 = ...`, insert: + +```zig +// Phase 3.E: initialize file table + console + install console fds +// 0/1/2 onto init so /bin/init inherits stdin/stdout/stderr. +file.init(); +console.init(); + +const console_fidx = file.alloc() orelse kprintf.panic("kmain: file.alloc console", .{}); +file.ftable[console_fidx].type = .Console; +file.ftable[console_fidx].ip = null; +file.ftable[console_fidx].off = 0; +// alloc gave us ref_count=1; bring to 3 (one per fd 0/1/2). +_ = file.dup(console_fidx); +_ = file.dup(console_fidx); +init_p.ofile[0] = console_fidx; +init_p.ofile[1] = console_fidx; +init_p.ofile[2] = console_fidx; +``` + +- [ ] **Step 3: Verify** + +Run: `zig fmt --check src/kernel/kmain.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 4: Regression e2e** + +Run: `zig build e2e-fs` +Expected: PASS — `fs_init.zig` writes via fd 1, but Task 10 hasn't routed sysWrite through file.write yet, so the existing UART path still fires. After Task 10 lands, the same write() call goes through file.write → console.write → uart.writeByte: same effect. + +- [ ] **Step 5: Commit** + +```bash +git add src/kernel/kmain.zig +git commit -m "feat(kmain): install console as fds 0/1/2 on PID 1 in FS_DEMO arm" +``` + +--- + +### Task 10: Route `sysWrite` and `sysRead` through `file.write` / `file.read` + +**Files:** +- Modify: `src/kernel/syscall.zig` (replace the hard-coded UART arm in `sysWrite` with a `file.write` dispatch; verify `sysRead` already dispatches through `file.read`) + +**Why this task here:** ties Tasks 8 + 9 together — once both are in place, fd 1/2 writes go through `file.write` → `console.write` → UART (unchanged user-visible behavior), and any future fd > 2 writes go through `file.write` → `inode.writei` (Task 11). Same for reads. + +- [ ] **Step 1: Replace `sysWrite` body** + +Find (~line 57): + +```zig +fn sysWrite(fd: u32, buf_va: u32, len: u32) u32 { + if (fd != 1 and fd != 2) { + return @bitCast(@as(i32, -9)); // -EBADF + } + setSum(); + var i: u32 = 0; + while (i < len) : (i += 1) { + const p: *const volatile u8 = @ptrFromInt(buf_va + i); + uart.writeByte(p.*); + } + clearSum(); + return len; +} +``` + +Replace with: + +```zig +fn sysWrite(fd: u32, buf_va: u32, len: u32) i32 { + if (fd >= proc.NOFILE) return -1; + const idx = proc.cur().ofile[fd]; + if (idx == 0) return -1; + return file.write(idx, buf_va, len); +} +``` + +(Return type changed from `u32` to `i32` — update the dispatch arm in Step 2.) + +- [ ] **Step 2: Update the `64` dispatch arm** + +Find: + +```zig +64 => tf.a0 = sysWrite(tf.a0, tf.a1, tf.a2), +``` + +Change to: + +```zig +64 => tf.a0 = @bitCast(sysWrite(tf.a0, tf.a1, tf.a2)), +``` + +- [ ] **Step 3: Verify `sysRead` is already correct** + +Find `fn sysRead`. Verify it already does: + +```zig +fn sysRead(fd: u32, buf_user_va: u32, n: u32) i32 { + if (fd >= proc.NOFILE) return -1; + const idx = proc.cur().ofile[fd]; + if (idx == 0) return -1; + return file.read(idx, buf_user_va, n); +} +``` + +If yes, no change. If different, normalize to the above. + +- [ ] **Step 4: Verify the `uart` import can be dropped (if no other callers)** + +Grep: + +```bash +grep -n "uart\." src/kernel/syscall.zig +``` + +If no remaining callers of `uart.*` show up, remove the `const uart = @import("uart.zig");` line at the top. Otherwise leave the import alone. + +- [ ] **Step 5: Verify** + +Run: `zig fmt --check src/kernel/syscall.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 6: Regression e2e** + +Run: `zig build e2e-fs` +Expected: PASS — `fs_init.zig` writes "hello from phase 3\n" via fd 1, which now goes through file.write → console.write → uart.writeByte. Output unchanged. + +Run: `zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/kernel/syscall.zig +git commit -m "refactor(syscall): route sysWrite through file.write (file table dispatch)" +``` + +--- + +### Task 11: Add `inode.iupdate` + +**Files:** +- Modify: `src/kernel/fs/inode.zig` (add the `iupdate` function — flushes the in-memory dinode back to its slot in the inode-table block via `bwrite`) + +**Why this task here:** `writei` (Task 12), `ialloc` (Task 13), `itrunc` (Task 14), and `iput`-on-zero (Task 14) all call `iupdate`. Lands as a self-contained leaf with no immediate caller — file builds cleanly. + +- [ ] **Step 1: Add `iupdate`** + +In `src/kernel/fs/inode.zig`, after the existing `pub fn iput` (~line 110), add: + +```zig +/// Flush this inode's in-memory dinode back to its slot in the inode table. +pub fn iupdate(ip: *InMemInode) void { + const blk = layout.INODE_START_BLK + ip.inum / layout.INODES_PER_BLOCK; + const slot = ip.inum % layout.INODES_PER_BLOCK; + const buf = bufcache.bread(blk); + const inodes: [*]layout.DiskInode = @ptrCast(@alignCast(&buf.data[0])); + inodes[slot] = ip.dinode; + bufcache.bwrite(buf); + bufcache.brelse(buf); +} +``` + +- [ ] **Step 2: Verify** + +Run: `zig fmt --check src/kernel/fs/inode.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/fs/inode.zig +git commit -m "feat(inode): add iupdate() — flush dinode back to inode-table block" +``` + +--- + +### Task 12: Extend `inode.bmap` with `for_write` flag + add `inode.writei` + +**Files:** +- Modify: `src/kernel/fs/inode.zig` (extend `bmap` with `for_write` parameter; add `writei`; update existing 3.D `bmap` callers in `readi` to pass `false`) + +**Why this task here:** `writei` is the core building block for every file write. `bmap` needs the `for_write` flag to allocate blocks on-demand; without it, writes to never-allocated blocks would silently fail. + +- [ ] **Step 1: Update `bmap` signature and body** + +Find the existing 3.D `bmap`: + +```zig +pub fn bmap(ip: *InMemInode, bn: u32) u32 { + if (bn < layout.NDIRECT) { + return ip.dinode.addrs[bn]; + } + const ix = bn - layout.NDIRECT; + if (ix >= layout.NINDIRECT) { + kprintf.panic("bmap: out of range bn={d}", .{bn}); + } + const ind = ip.dinode.addrs[layout.NDIRECT]; + if (ind == 0) return 0; + const buf = bufcache.bread(ind); + const slots: [*]const u32 = @ptrCast(@alignCast(&buf.data[0])); + const addr = slots[ix]; + bufcache.brelse(buf); + return addr; +} +``` + +Replace with: + +```zig +pub fn bmap(ip: *InMemInode, bn: u32, for_write: bool) u32 { + if (bn < layout.NDIRECT) { + var addr = ip.dinode.addrs[bn]; + if (addr == 0 and for_write) { + addr = balloc.alloc(); + if (addr == 0) return 0; + ip.dinode.addrs[bn] = addr; + // Caller is responsible for iupdate after the write. + } + return addr; + } + + const ix = bn - layout.NDIRECT; + if (ix >= layout.NINDIRECT) { + kprintf.panic("bmap: out of range bn={d}", .{bn}); + } + + var ind = ip.dinode.addrs[layout.NDIRECT]; + if (ind == 0) { + if (!for_write) return 0; + ind = balloc.alloc(); + if (ind == 0) return 0; + ip.dinode.addrs[layout.NDIRECT] = ind; + // Zero-fill the new indirect block so unused entries read back as 0. + const zbuf = bufcache.bread(ind); + @memset(&zbuf.data, 0); + bufcache.bwrite(zbuf); + bufcache.brelse(zbuf); + } + + const buf = bufcache.bread(ind); + const slots: [*]u32 = @ptrCast(@alignCast(&buf.data[0])); + var addr = slots[ix]; + if (addr == 0 and for_write) { + addr = balloc.alloc(); + if (addr == 0) { + bufcache.brelse(buf); + return 0; + } + slots[ix] = addr; + bufcache.bwrite(buf); + } + bufcache.brelse(buf); + return addr; +} +``` + +- [ ] **Step 2: Update the existing `readi` to pass `false`** + +Find the `pub fn readi` body. Inside its loop, find the `bmap(ip, ...)` call (likely `bmap(ip, bn)`). Change to: + +```zig +const blk = bmap(ip, bn, false); +``` + +- [ ] **Step 3: Add `writei`** + +After `pub fn readi`, add: + +```zig +/// Write `n` bytes from `src` to inode `ip` starting at offset `off`. +/// Returns bytes actually written (may be < n if disk fills) or -1 on +/// bad arguments. +pub fn writei(ip: *InMemInode, src: [*]const u8, off: u32, n: u32) i32 { + if (off + n > layout.MAX_FILE_BLOCKS * layout.BLOCK_SIZE) return -1; + + var written: u32 = 0; + while (written < n) { + const cur_off = off + written; + const bn = cur_off / layout.BLOCK_SIZE; + const within = cur_off % layout.BLOCK_SIZE; + const remain_block = layout.BLOCK_SIZE - within; + const remain_total = n - written; + const chunk = if (remain_block < remain_total) remain_block else remain_total; + + const blk = bmap(ip, bn, true); + if (blk == 0) break; // out of disk + + const buf = bufcache.bread(blk); + var i: u32 = 0; + while (i < chunk) : (i += 1) { + buf.data[within + i] = src[written + i]; + } + bufcache.bwrite(buf); + bufcache.brelse(buf); + written += chunk; + } + + if (off + written > ip.dinode.size) { + ip.dinode.size = off + written; + } + iupdate(ip); + return @intCast(written); +} +``` + +- [ ] **Step 4: Add `balloc` import if missing** + +Verify the top of `src/kernel/fs/inode.zig` includes: + +```zig +const balloc = @import("balloc.zig"); +``` + +If missing, add it. + +- [ ] **Step 5: Verify** + +Run: `zig fmt --check src/kernel/fs/inode.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 6: Regression e2e** + +Run: `zig build e2e-fs` +Expected: PASS — read path unchanged (now calls `bmap(ip, bn, false)` which is byte-equivalent to the old `bmap(ip, bn)`). + +- [ ] **Step 7: Now revisit Task 8's stubbed `file.write` Inode arm** + +If you stubbed it in Task 8, replace the stub body now (writei is real). Otherwise verify it compiles + behaves correctly: + +```zig +pub fn write(idx: u32, src_user_va: u32, n: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + + if (f.type == .Console) { + return console.write(src_user_va, n); + } + if (f.type != .Inode or f.ip == null) return -1; + + const want = if (n > write_kbuf.len) write_kbuf.len else n; + + setSum(); + var i: u32 = 0; + while (i < want) : (i += 1) { + const src_p: *const volatile u8 = @ptrFromInt(src_user_va + i); + write_kbuf[i] = src_p.*; + } + clearSum(); + + inode.ilock(f.ip.?); + const wrote = inode.writei(f.ip.?, &write_kbuf, f.off, @intCast(want)); + inode.iunlock(f.ip.?); + + if (wrote > 0) f.off += @intCast(wrote); + return wrote; +} +``` + +- [ ] **Step 8: Final verify** + +Run: `zig build kernel-fs` +Expected: PASS. + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/kernel/fs/inode.zig src/kernel/file.zig +git commit -m "feat(inode): add writei + bmap.for_write flag (lazy block alloc)" +``` + +--- + +### Task 13: Add `inode.ialloc` + +**Files:** +- Modify: `src/kernel/fs/inode.zig` (add the `ialloc` function — finds first free disk inode, claims it on disk, returns its in-memory cache entry) + +**Why this task here:** `fsops.create` (Task 16) calls `ialloc` to materialize new files and directories. Lands as a leaf addition with no immediate caller — file builds cleanly. + +- [ ] **Step 1: Add `ialloc`** + +In `src/kernel/fs/inode.zig`, after `pub fn iupdate`, add: + +```zig +/// Find the first free disk inode (type == .Free), claim it with the +/// given type (writes back via bwrite), and return the in-memory cache +/// entry holding it. Returns null on full inode table. +pub fn ialloc(itype: layout.FileType) ?*InMemInode { + const std = @import("std"); + + var inum: u32 = 1; // inum 0 is the "no inode" sentinel; root is inum 1 + while (inum < layout.NINODES) : (inum += 1) { + const blk = layout.INODE_START_BLK + inum / layout.INODES_PER_BLOCK; + const slot = inum % layout.INODES_PER_BLOCK; + const buf = bufcache.bread(blk); + const inodes: [*]layout.DiskInode = @ptrCast(@alignCast(&buf.data[0])); + if (inodes[slot].type == .Free) { + inodes[slot] = .{ + .type = itype, + .nlink = 1, + .size = 0, + .addrs = std.mem.zeroes([layout.NDIRECT + 1]u32), + ._reserved = std.mem.zeroes([4]u8), + }; + bufcache.bwrite(buf); + bufcache.brelse(buf); + + // Bring it into the in-memory cache. + const ip = iget(inum); + ilock(ip); + // ip.dinode now reflects what we just wrote. + iunlock(ip); + return ip; + } + bufcache.brelse(buf); + } + return null; +} +``` + +- [ ] **Step 2: Verify** + +Run: `zig fmt --check src/kernel/fs/inode.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/fs/inode.zig +git commit -m "feat(inode): add ialloc() — claim first free inode + return cached entry" +``` + +--- + +### Task 14: Add `inode.itrunc` and the `iput`-on-zero on-disk truncate path + +**Files:** +- Modify: `src/kernel/fs/inode.zig` (add `itrunc`; extend `iput` to call `itrunc` + flip type to `.Free` when last in-memory ref drops AND `nlink == 0`) + +**Why this task here:** `O_TRUNC` (Task 17) and `unlink` (Task 16, via `iput`) both need to free all data blocks when an inode loses its last link. Without this, deleted files would leak disk blocks forever. + +- [ ] **Step 1: Add `itrunc`** + +After `pub fn ialloc`, add: + +```zig +/// Free every block held by `ip` (direct + indirect) and reset size to 0. +/// Caller must hold ip.busy (i.e., must have ilock'd ip). +pub fn itrunc(ip: *InMemInode) void { + var i: u32 = 0; + while (i < layout.NDIRECT) : (i += 1) { + if (ip.dinode.addrs[i] != 0) { + balloc.free(ip.dinode.addrs[i]); + ip.dinode.addrs[i] = 0; + } + } + if (ip.dinode.addrs[layout.NDIRECT] != 0) { + const buf = bufcache.bread(ip.dinode.addrs[layout.NDIRECT]); + const slots: [*]const u32 = @ptrCast(@alignCast(&buf.data[0])); + var j: u32 = 0; + while (j < layout.NINDIRECT) : (j += 1) { + if (slots[j] != 0) balloc.free(slots[j]); + } + bufcache.brelse(buf); + balloc.free(ip.dinode.addrs[layout.NDIRECT]); + ip.dinode.addrs[layout.NDIRECT] = 0; + } + ip.dinode.size = 0; + iupdate(ip); +} +``` + +- [ ] **Step 2: Extend `iput`** + +Find the existing 3.D `pub fn iput`: + +```zig +pub fn iput(ip: *InMemInode) void { + if (ip.refs > 0) ip.refs -= 1; +} +``` + +Replace with: + +```zig +pub fn iput(ip: *InMemInode) void { + // If this is the last in-memory ref AND the file has been unlinked + // (nlink == 0), it's our job to free its on-disk resources. + if (ip.refs == 1 and ip.valid and ip.dinode.nlink == 0) { + // ilock-equivalent: ip is single-refed and we're the only caller, + // so busy is necessarily false. Set busy to keep the invariant + // (in case any future caller checks). + ip.busy = true; + itrunc(ip); + ip.dinode.type = .Free; + iupdate(ip); + ip.valid = false; + ip.busy = false; + } + if (ip.refs > 0) ip.refs -= 1; +} +``` + +- [ ] **Step 3: Verify** + +Run: `zig fmt --check src/kernel/fs/inode.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 4: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — `e2e-fs` only opens + reads + closes; the new `iput` path runs but `nlink == 1` so the truncate branch doesn't fire. + +- [ ] **Step 5: Commit** + +```bash +git add src/kernel/fs/inode.zig +git commit -m "feat(inode): add itrunc + iput-on-zero truncate (free blocks when nlink==0)" +``` + +--- + +### Task 15: Replace `dir.dirlink` stub + add `dir.dirunlink` + +**Files:** +- Modify: `src/kernel/fs/dir.zig` (replace the 3.D `dirlink` stub with the real append-or-find-free-slot impl; add `dirunlink`; refactor a private `nameEq` helper) + +**Why this task here:** `fsops.create` (Task 16) calls `dirlink`; `fsops.unlink` (Task 16) calls `dirunlink`. Both must exist before fsops compiles. + +- [ ] **Step 1: Add a private `nameEq` helper** + +In `src/kernel/fs/dir.zig`, near the top of the file (after imports, before any `pub fn`), add: + +```zig +/// Compare a directory entry's NUL-padded name slot against an exact-length target. +fn nameEq(slot_name: []const u8, target: []const u8) bool { + if (target.len >= slot_name.len) return false; + var i: u32 = 0; + while (i < target.len) : (i += 1) { + if (slot_name[i] != target[i]) return false; + } + return slot_name[target.len] == 0; +} +``` + +If the existing `dirlookup` does an inline name compare, refactor it to call `nameEq` so the same logic is shared. + +- [ ] **Step 2: Replace `dirlink` stub** + +Find the existing 3.D `pub fn dirlink`: + +```zig +pub fn dirlink(dir: *InMemInode, name: []const u8, inum: u16) bool { + _ = dir; _ = name; _ = inum; + return false; +} +``` + +Replace with: + +```zig +pub fn dirlink(dir: *InMemInode, name: []const u8, inum: u16) bool { + if (name.len == 0 or name.len >= layout.DIR_NAME_LEN) return false; + + // Reject duplicates. + var off: u32 = 0; + var de: layout.DirEntry = undefined; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) return false; + if (de.inum != 0 and nameEq(de.name[0..], name)) return false; + } + + // Find first free slot OR fall through to append at end. + off = 0; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) break; + if (de.inum == 0) break; + } + // Note: if the loop ran to completion without finding a free slot, + // `off == dir.dinode.size` — writei will extend the directory. + + var entry: layout.DirEntry = .{ .inum = inum, .name = std.mem.zeroes([layout.DIR_NAME_LEN]u8) }; + var i: u32 = 0; + while (i < name.len) : (i += 1) entry.name[i] = name[i]; + // Remaining bytes already zero from std.mem.zeroes. + + const wrote = inode.writei(dir, @ptrCast(&entry), off, @sizeOf(layout.DirEntry)); + return wrote == @sizeOf(layout.DirEntry); +} +``` + +(Make sure `const std = @import("std");` is at the top of the file.) + +- [ ] **Step 3: Add `dirunlink`** + +After `dirlink`, add: + +```zig +pub fn dirunlink(dir: *InMemInode, name: []const u8) bool { + var off: u32 = 0; + var de: layout.DirEntry = undefined; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) return false; + if (de.inum != 0 and nameEq(de.name[0..], name)) { + de.inum = 0; + de.name = std.mem.zeroes([layout.DIR_NAME_LEN]u8); + const wrote = inode.writei(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + return wrote == @sizeOf(layout.DirEntry); + } + } + return false; +} +``` + +- [ ] **Step 4: Verify** + +Run: `zig fmt --check src/kernel/fs/dir.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 5: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — `dirlookup` semantics unchanged (refactored into `nameEq`). + +- [ ] **Step 6: Commit** + +```bash +git add src/kernel/fs/dir.zig +git commit -m "feat(dir): real dirlink (append/find-free) + dirunlink (zero slot)" +``` + +--- + +### Task 16: Add `fs/fsops.zig` (`create` + `unlink` glue) + +**Files:** +- Create: `src/kernel/fs/fsops.zig` (wraps `nameiparent + dirlookup + ialloc + dirlink` for create; `nameiparent + dirlookup + dirunlink + iput` for unlink) + +**Why this task here:** the `sysOpenat` O_CREAT path (Task 17), `sysMkdirat` (Task 18), and `sysUnlinkat` (Task 19) all dispatch to `fsops.create` / `fsops.unlink`. Lands as a self-contained module with no immediate callers. + +- [ ] **Step 1: Create `src/kernel/fs/fsops.zig`** + +```zig +// src/kernel/fs/fsops.zig — Phase 3.E create + unlink glue. +// +// Bridges the syscall layer (sysOpenat O_CREAT, sysMkdirat, sysUnlinkat) +// with the FS primitives (path, dir, inode, balloc). +// +// API: +// create(path, type) -> ?*InMemInode +// - For .File: idempotent open-or-create (returns existing if a File +// at `path` already exists; null if a non-File exists there). +// - For .Dir: strictly create-new (null if anything at `path` exists). +// unlink(path) -> i32 +// - Decrements nlink; on zero, frees blocks via inode.iput truncate. +// - Refuses to unlink "." or ".." or non-empty directories. +// - Returns 0 on success, -1 on any failure. + +const std = @import("std"); +const layout = @import("layout.zig"); +const inode = @import("inode.zig"); +const path_mod = @import("path.zig"); +const dir = @import("dir.zig"); + +pub fn create(path: []const u8, itype: layout.FileType) ?*inode.InMemInode { + var leaf: [layout.DIR_NAME_LEN]u8 = undefined; + const parent = path_mod.nameiparent(path, &leaf) orelse return null; + + const leaf_slice = leafSlice(&leaf); + if (leaf_slice.len == 0) { + inode.iput(parent); + return null; + } + + inode.ilock(parent); + + // Existing entry? + if (dir.dirlookup(parent, leaf_slice)) |existing_inum| { + inode.iunlock(parent); + inode.iput(parent); + const existing_ip = inode.iget(existing_inum); + inode.ilock(existing_ip); + if (itype == .File and existing_ip.dinode.type == .File) { + inode.iunlock(existing_ip); + return existing_ip; // idempotent open-or-create for files + } + inode.iunlock(existing_ip); + inode.iput(existing_ip); + return null; + } + + const new_ip = inode.ialloc(itype) orelse { + inode.iunlock(parent); + inode.iput(parent); + return null; + }; + + // For dirs, install . and .. entries first. + if (itype == .Dir) { + inode.ilock(new_ip); + const ok_dot = dir.dirlink(new_ip, ".", @intCast(new_ip.inum)); + const ok_dotdot = dir.dirlink(new_ip, "..", @intCast(parent.inum)); + if (!ok_dot or !ok_dotdot) { + inode.iunlock(new_ip); + inode.iput(new_ip); + inode.iunlock(parent); + inode.iput(parent); + return null; + } + // Bump parent's nlink for the new "..". + parent.dinode.nlink += 1; + inode.iupdate(parent); + inode.iunlock(new_ip); + } + + if (!dir.dirlink(parent, leaf_slice, @intCast(new_ip.inum))) { + inode.iunlock(parent); + inode.iput(parent); + inode.iput(new_ip); + return null; + } + + inode.iunlock(parent); + inode.iput(parent); + return new_ip; +} + +pub fn unlink(path: []const u8) i32 { + var leaf: [layout.DIR_NAME_LEN]u8 = undefined; + const parent = path_mod.nameiparent(path, &leaf) orelse return -1; + + const leaf_slice = leafSlice(&leaf); + if (leaf_slice.len == 0 or + (leaf_slice.len == 1 and leaf_slice[0] == '.') or + (leaf_slice.len == 2 and leaf_slice[0] == '.' and leaf_slice[1] == '.')) + { + inode.iput(parent); + return -1; + } + + inode.ilock(parent); + const target_inum = dir.dirlookup(parent, leaf_slice) orelse { + inode.iunlock(parent); + inode.iput(parent); + return -1; + }; + + const target_ip = inode.iget(target_inum); + inode.ilock(target_ip); + + if (target_ip.dinode.type == .Dir and !isDirEmpty(target_ip)) { + inode.iunlock(target_ip); + inode.iput(target_ip); + inode.iunlock(parent); + inode.iput(parent); + return -1; + } + + _ = dir.dirunlink(parent, leaf_slice); + + if (target_ip.dinode.type == .Dir) { + // Drop the parent's nlink that mkdir bumped. + parent.dinode.nlink -= 1; + inode.iupdate(parent); + } + + target_ip.dinode.nlink -= 1; + inode.iupdate(target_ip); + inode.iunlock(target_ip); + inode.iput(target_ip); // triggers truncate if nlink == 0 + last ref + + inode.iunlock(parent); + inode.iput(parent); + return 0; +} + +fn leafSlice(leaf: *const [layout.DIR_NAME_LEN]u8) []const u8 { + var n: u32 = 0; + while (n < leaf.len and leaf[n] != 0) : (n += 1) {} + return leaf[0..n]; +} + +fn isDirEmpty(d: *inode.InMemInode) bool { + var off: u32 = 2 * @sizeOf(layout.DirEntry); // skip . and .. + var de: layout.DirEntry = undefined; + while (off < d.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(d, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) break; + if (de.inum != 0) return false; + } + return true; +} + +// Host tests for the leaf helpers (run via `zig build test`). +const testing = std.testing; + +test "leafSlice trims at NUL" { + var buf: [layout.DIR_NAME_LEN]u8 = std.mem.zeroes([layout.DIR_NAME_LEN]u8); + @memcpy(buf[0..3], "abc"); + const slice = leafSlice(&buf); + try testing.expectEqual(@as(usize, 3), slice.len); + try testing.expectEqualStrings("abc", slice); +} + +test "leafSlice empty when first byte is NUL" { + const buf: [layout.DIR_NAME_LEN]u8 = std.mem.zeroes([layout.DIR_NAME_LEN]u8); + const slice = leafSlice(&buf); + try testing.expectEqual(@as(usize, 0), slice.len); +} +``` + +- [ ] **Step 2: Verify** + +Run: `zig fmt --check src/kernel/fs/fsops.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS — module compiles but is unused. + +Run: `zig build test` +Expected: PASS — `leafSlice` host tests pass. + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/fs/fsops.zig +git commit -m "feat(fsops): add create + unlink glue (sits between syscall and FS)" +``` + +--- + +### Task 17: Extend `sysOpenat` with O_CREAT, O_TRUNC, O_APPEND + +**Files:** +- Modify: `src/kernel/syscall.zig` (rewrite `sysOpenat` to handle the three new flags; add the flag constants) + +**Why this task here:** the shell does `echo hi > /tmp/x` — the `>` redirect calls `openat(0, "/tmp/x", O_WRONLY | O_CREAT | O_TRUNC)`. Without this, the shell can't create files. + +- [ ] **Step 1: Add `fsops` import** + +In `src/kernel/syscall.zig`, near the existing `const path_mod = @import("fs/path.zig");`, add: + +```zig +const fsops = @import("fs/fsops.zig"); +``` + +- [ ] **Step 2: Add flag constants** + +Near the top of the file, after the imports and before the existing `const SSTATUS_SUM`, add: + +```zig +pub const O_RDONLY: u32 = 0x000; +pub const O_WRONLY: u32 = 0x001; +pub const O_RDWR: u32 = 0x002; +pub const O_CREAT: u32 = 0x040; +pub const O_TRUNC: u32 = 0x200; +pub const O_APPEND: u32 = 0x400; +``` + +- [ ] **Step 3: Rewrite `sysOpenat`** + +Find the existing `fn sysOpenat`. Replace its entire body with: + +```zig +fn sysOpenat(dirfd: u32, path_user_va: u32, flags: u32) i32 { + _ = dirfd; + + var pbuf: [path_mod.MAX_PATH]u8 = undefined; + const p = copyStrFromUser(path_user_va, &pbuf) orelse return -1; + + // Resolve, or O_CREAT a new file. + const ip = path_mod.namei(p) orelse blk: { + if ((flags & O_CREAT) == 0) return -1; + break :blk fsops.create(p, .File) orelse return -1; + }; + + // O_TRUNC on a regular file: free all data blocks, reset size to 0. + if ((flags & O_TRUNC) != 0) { + inode.ilock(ip); + if (ip.dinode.type == .File) inode.itrunc(ip); + inode.iunlock(ip); + } + + const fidx = file.alloc() orelse { + inode.iput(ip); + return -1; + }; + file.ftable[fidx].type = .Inode; + file.ftable[fidx].ip = ip; + + // O_APPEND: seek to EOF. + if ((flags & O_APPEND) != 0) { + inode.ilock(ip); + file.ftable[fidx].off = ip.dinode.size; + inode.iunlock(ip); + } else { + file.ftable[fidx].off = 0; + } + + const cur_p = proc.cur(); + var fd: u32 = 0; + while (fd < proc.NOFILE) : (fd += 1) { + if (cur_p.ofile[fd] == 0) { + cur_p.ofile[fd] = fidx; + return @intCast(fd); + } + } + file.close(fidx); + return -1; +} +``` + +- [ ] **Step 4: Verify** + +Run: `zig fmt --check src/kernel/syscall.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 5: Regression e2e** + +Run: `zig build e2e-fs` +Expected: PASS — `fs_init.zig` calls `openat(0, "/etc/motd", 0)` (no flags), so the new code paths aren't exercised. Behavior unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add src/kernel/syscall.zig +git commit -m "feat(syscall): sysOpenat handles O_CREAT, O_TRUNC, O_APPEND" +``` + +--- + +### Task 18: Wire `sysMkdirat` (syscall 34) + +**Files:** +- Modify: `src/kernel/syscall.zig` (add `sysMkdirat` + dispatch arm 34) + +**Why this task here:** the milestone session doesn't use `mkdir`, but the spec explicitly calls it out as a 3.E syscall, and the `mkdir` userland binary depends on it. Lands as a leaf addition. + +- [ ] **Step 1: Add `sysMkdirat`** + +In `src/kernel/syscall.zig`, after `sysUnlinkat` slot (which doesn't exist yet — add both functions adjacent to each other; we'll add `sysUnlinkat` in Task 19). For now, add `sysMkdirat` after `sysFstat` (~line 177): + +```zig +/// 34 mkdirat(dirfd, path) — 3.E ignores dirfd. Returns 0 / -1. +fn sysMkdirat(dirfd: u32, path_va: u32) i32 { + _ = dirfd; + var pbuf: [path_mod.MAX_PATH]u8 = undefined; + const p = copyStrFromUser(path_va, &pbuf) orelse return -1; + const ip = fsops.create(p, .Dir) orelse return -1; + inode.iput(ip); + return 0; +} +``` + +- [ ] **Step 2: Add the 34 dispatch arm** + +Find the `pub fn dispatch(tf: *trap.TrapFrame) void { switch (tf.a7) { ... } }` block. Find the existing arm: + +```zig +17 => tf.a0 = @bitCast(sysGetcwd(tf.a0, tf.a1)), +``` + +Add a new arm right above it (numerical order): + +```zig +17 => tf.a0 = @bitCast(sysGetcwd(tf.a0, tf.a1)), +34 => tf.a0 = @bitCast(sysMkdirat(tf.a0, tf.a1)), +``` + +(Order in the switch doesn't matter for correctness, but numerical sort matches the 3.D convention.) + +- [ ] **Step 3: Verify** + +Run: `zig fmt --check src/kernel/syscall.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 4: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/kernel/syscall.zig +git commit -m "feat(syscall): add sysMkdirat (34) via fsops.create" +``` + +--- + +### Task 19: Wire `sysUnlinkat` (syscall 35) + +**Files:** +- Modify: `src/kernel/syscall.zig` (add `sysUnlinkat` + dispatch arm 35) + +**Why this task here:** the milestone session does `rm /tmp/x`. Lands as the last syscall addition before we move into userland. + +- [ ] **Step 1: Add `sysUnlinkat`** + +In `src/kernel/syscall.zig`, immediately after the `sysMkdirat` from Task 18, add: + +```zig +/// 35 unlinkat(dirfd, path, flags) — 3.E ignores dirfd and flags. +/// Returns 0 / -1. +fn sysUnlinkat(dirfd: u32, path_va: u32, flags: u32) i32 { + _ = dirfd; + _ = flags; + var pbuf: [path_mod.MAX_PATH]u8 = undefined; + const p = copyStrFromUser(path_va, &pbuf) orelse return -1; + return fsops.unlink(p); +} +``` + +- [ ] **Step 2: Add the 35 dispatch arm** + +Find the dispatch switch. Add right after the 34 arm from Task 18: + +```zig +34 => tf.a0 = @bitCast(sysMkdirat(tf.a0, tf.a1)), +35 => tf.a0 = @bitCast(sysUnlinkat(tf.a0, tf.a1, tf.a2)), +``` + +- [ ] **Step 3: Verify** + +Run: `zig fmt --check src/kernel/syscall.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS. + +- [ ] **Step 4: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/kernel/syscall.zig +git commit -m "feat(syscall): add sysUnlinkat (35) via fsops.unlink" +``` + +--- + +### Task 20: Add `src/kernel/user/lib/start.S` + +**Files:** +- Create: `src/kernel/user/lib/start.S` (RV32 `_start`: parse argc/argv, call main, ecall exit) + +**Why this task here:** every new userland binary entry point. Lands as a leaf — no callers yet. The corresponding linker change is just "include start.o in the link"; that wiring lands in Task 24's `addUserBinary` helper. + +- [ ] **Step 1: Create `src/kernel/user/lib/` directory + `start.S`** + +```asm +# src/kernel/user/lib/start.S — Phase 3.E userland entry point. +# +# proc.exec sets sp to the System-V tail: +# sp+0: argc (u32) +# sp+4: argv[0] (u32 — pointer to first arg string) +# sp+8: argv[1] (u32) +# ... +# sp+4+4*argc: 0 (NULL terminator) +# sp+...: argv strings, NUL-terminated +# +# We pass (argc, argv) to main via a0/a1 per RV32 calling convention, +# then ecall exit on return. + +.section .text._start, "ax", @progbits +.globl _start +_start: + lw a0, 0(sp) # a0 = argc + addi a1, sp, 4 # a1 = &argv[0] + call main # main(argc, argv) -> a0 = exit status + li a7, 93 # SYS_exit + ecall +1: j 1b # never returns +``` + +- [ ] **Step 2: Verify the file is well-formed** + +Run: `cat src/kernel/user/lib/start.S | head` +Expected: shows the file content. + +(There's no compile check yet — Task 24 wires this into a real link target. For now we just need the file to exist.) + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — `start.S` is unreferenced. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/user/lib/start.S +git commit -m "feat(user/lib): add start.S — RV32 _start parses argc/argv + ecalls exit" +``` + +--- + +### Task 21: Add `src/kernel/user/lib/usys.S` + +**Files:** +- Create: `src/kernel/user/lib/usys.S` (one syscall stub per ABI entry) + +**Why this task here:** `ulib.zig` (Task 22) declares these as `extern fn`. The symbols must exist before any user binary links. + +- [ ] **Step 1: Create `usys.S`** + +```asm +# src/kernel/user/lib/usys.S — Phase 3.E syscall stubs. +# +# One stub per syscall the userland calls: load syscall number into a7, +# pass-through a0..a5 (registers already populated by the Zig caller), +# ecall, return whatever the kernel put in a0. + +.macro SYSCALL name, num + .section .text.\name, "ax", @progbits + .globl \name +\name: + li a7, \num + ecall + ret +.endm + +SYSCALL getcwd, 17 +SYSCALL mkdirat, 34 +SYSCALL unlinkat, 35 +SYSCALL chdir, 49 +SYSCALL openat, 56 +SYSCALL close, 57 +SYSCALL lseek, 62 +SYSCALL read, 63 +SYSCALL write, 64 +SYSCALL fstat, 80 +SYSCALL exit, 93 +SYSCALL yield, 124 +SYSCALL getpid, 172 +SYSCALL sbrk, 214 +SYSCALL fork, 220 +SYSCALL exec, 221 +SYSCALL wait, 260 +SYSCALL set_fg_pid, 5000 +SYSCALL console_set_mode, 5001 +``` + +- [ ] **Step 2: Verify the file is well-formed** + +Run: `head -5 src/kernel/user/lib/usys.S` +Expected: shows the macro + first stub. + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — `usys.S` is unreferenced. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/user/lib/usys.S +git commit -m "feat(user/lib): add usys.S — 19 syscall stubs (li a7; ecall; ret)" +``` + +--- + +### Task 22: Add `src/kernel/user/lib/ulib.zig` + +**Files:** +- Create: `src/kernel/user/lib/ulib.zig` (mem*/str* helpers + extern fn syscall declarations + Stat/O_* constants + getline helper) + +**Why this task here:** every user binary imports `ulib`. Must exist before `uprintf.zig` (which depends on it) and any user binary. + +- [ ] **Step 1: Create `ulib.zig`** + +```zig +// src/kernel/user/lib/ulib.zig — Phase 3.E userspace standard library. +// +// All the boilerplate every user binary needs: mem*/str* helpers, syscall +// extern declarations (defined in usys.S), Stat layout, O_* flag bits, +// and a one-byte-at-a-time getline helper. + +pub fn strlen(s: [*:0]const u8) u32 { + var n: u32 = 0; + while (s[n] != 0) : (n += 1) {} + return n; +} + +pub fn strcmp(a: [*:0]const u8, b: [*:0]const u8) i32 { + var i: u32 = 0; + while (a[i] != 0 and b[i] != 0 and a[i] == b[i]) : (i += 1) {} + return @as(i32, a[i]) - @as(i32, b[i]); +} + +pub fn strncmp(a: [*]const u8, b: [*]const u8, n: u32) i32 { + var i: u32 = 0; + while (i < n) : (i += 1) { + if (a[i] != b[i]) return @as(i32, a[i]) - @as(i32, b[i]); + } + return 0; +} + +pub fn memmove(dst: [*]u8, src: [*]const u8, n: u32) void { + if (@intFromPtr(dst) < @intFromPtr(src)) { + var i: u32 = 0; + while (i < n) : (i += 1) dst[i] = src[i]; + } else { + var i: u32 = n; + while (i > 0) { + i -= 1; + dst[i] = src[i]; + } + } +} + +pub fn memset(dst: [*]u8, c: u8, n: u32) void { + var i: u32 = 0; + while (i < n) : (i += 1) dst[i] = c; +} + +pub fn memcmp(a: [*]const u8, b: [*]const u8, n: u32) i32 { + var i: u32 = 0; + while (i < n) : (i += 1) { + if (a[i] != b[i]) return @as(i32, a[i]) - @as(i32, b[i]); + } + return 0; +} + +pub fn atoi(s: [*:0]const u8) i32 { + var i: u32 = 0; + var sign: i32 = 1; + if (s[0] == '-') { + sign = -1; + i = 1; + } + var n: i32 = 0; + while (s[i] >= '0' and s[i] <= '9') : (i += 1) { + n = n * 10 + @as(i32, s[i] - '0'); + } + return sign * n; +} + +/// Read a line from `fd` into `buf`. Returns bytes read (incl. trailing `\n` +/// if present), or 0 on EOF, or -1 on error. +pub fn getline(fd: u32, buf: [*]u8, max: u32) i32 { + var n: u32 = 0; + while (n < max) { + const got = read(fd, buf + n, 1); + if (got <= 0) return if (n == 0) got else @intCast(n); + const c = buf[n]; + n += 1; + if (c == '\n') return @intCast(n); + } + return @intCast(n); +} + +// Syscall stubs (defined in usys.S — link-time symbols). +pub extern fn read(fd: u32, buf: [*]u8, n: u32) i32; +pub extern fn write(fd: u32, buf: [*]const u8, n: u32) i32; +pub extern fn close(fd: u32) i32; +pub extern fn openat(dirfd: u32, path: [*:0]const u8, flags: u32) i32; +pub extern fn lseek(fd: u32, off: i32, whence: u32) i32; +pub extern fn fstat(fd: u32, st: *Stat) i32; +pub extern fn mkdirat(dirfd: u32, path: [*:0]const u8) i32; +pub extern fn unlinkat(dirfd: u32, path: [*:0]const u8, flags: u32) i32; +pub extern fn chdir(path: [*:0]const u8) i32; +pub extern fn getcwd(buf: [*]u8, sz: u32) i32; +pub extern fn fork() i32; +pub extern fn exec(path: [*:0]const u8, argv: [*]const ?[*:0]const u8) i32; +pub extern fn wait(status: ?*i32) i32; +pub extern fn exit(status: i32) noreturn; +pub extern fn getpid() u32; +pub extern fn yield() u32; +pub extern fn sbrk(incr: i32) i32; +pub extern fn set_fg_pid(pid: u32) u32; +pub extern fn console_set_mode(mode: u32) u32; + +// Stat layout — must match kernel file.zig::Stat. +pub const Stat = extern struct { + type: u32, + size: u32, +}; + +pub const STAT_FILE: u32 = 1; +pub const STAT_DIR: u32 = 2; + +// Flag bits — must match kernel syscall.zig. +pub const O_RDONLY: u32 = 0x000; +pub const O_WRONLY: u32 = 0x001; +pub const O_RDWR: u32 = 0x002; +pub const O_CREAT: u32 = 0x040; +pub const O_TRUNC: u32 = 0x200; +pub const O_APPEND: u32 = 0x400; + +// Console modes — must match kernel console.zig. +pub const CONSOLE_COOKED: u32 = 0; +pub const CONSOLE_RAW: u32 = 1; +``` + +- [ ] **Step 2: Verify** + +Run: `zig fmt --check src/kernel/user/lib/ulib.zig` +Expected: PASS. + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/user/lib/ulib.zig +git commit -m "feat(user/lib): add ulib.zig — mem*/str* + syscall externs + Stat/O_*" +``` + +--- + +### Task 23: Add `src/kernel/user/lib/uprintf.zig` + +**Files:** +- Create: `src/kernel/user/lib/uprintf.zig` (60-line `printf(fd, fmt, args)` for `%d`, `%u`, `%x`, `%s`, `%c`, `%%`) + +**Why this task here:** the shell, ls, echo, etc., all print formatted output via `uprintf`. Last building block before user binaries. + +- [ ] **Step 1: Create `uprintf.zig`** + +```zig +// src/kernel/user/lib/uprintf.zig — minimal printf for fd. +// +// Supports %d (i32), %u (u32 decimal), %x (u32 hex lowercase), +// %s (NUL-terminated string), %c (u8), %% (literal '%'). +// +// Args is a slice of the Arg union — caller passes e.g.: +// printf(1, "hello %s, pid %d\n", &.{ .{ .s = "world" }, .{ .i = pid } }); + +const ulib = @import("ulib.zig"); + +fn putc(fd: u32, c: u8) void { + var b: [1]u8 = .{c}; + _ = ulib.write(fd, &b, 1); +} + +fn putStr(fd: u32, s: [*:0]const u8) void { + var i: u32 = 0; + while (s[i] != 0) : (i += 1) putc(fd, s[i]); +} + +fn putUint(fd: u32, n: u32, base: u32) void { + var buf: [16]u8 = undefined; + var i: u32 = 0; + var v = n; + if (v == 0) { + putc(fd, '0'); + return; + } + while (v > 0) { + const d = v % base; + buf[i] = if (d < 10) @intCast('0' + d) else @intCast('a' + d - 10); + i += 1; + v /= base; + } + while (i > 0) { + i -= 1; + putc(fd, buf[i]); + } +} + +fn putInt(fd: u32, n: i32, base: u32) void { + if (n < 0) { + putc(fd, '-'); + putUint(fd, @intCast(-n), base); + } else { + putUint(fd, @intCast(n), base); + } +} + +pub const Arg = union(enum) { + i: i32, + u: u32, + s: [*:0]const u8, + c: u8, +}; + +pub fn printf(fd: u32, fmt: [*:0]const u8, args: []const Arg) void { + var i: u32 = 0; + var ai: u32 = 0; + while (fmt[i] != 0) : (i += 1) { + if (fmt[i] != '%') { + putc(fd, fmt[i]); + continue; + } + i += 1; + if (fmt[i] == 0) return; + switch (fmt[i]) { + 'd' => { putInt(fd, args[ai].i, 10); ai += 1; }, + 'u' => { putUint(fd, args[ai].u, 10); ai += 1; }, + 'x' => { putUint(fd, args[ai].u, 16); ai += 1; }, + 's' => { putStr(fd, args[ai].s); ai += 1; }, + 'c' => { putc(fd, args[ai].c); ai += 1; }, + '%' => putc(fd, '%'), + else => { + putc(fd, '%'); + putc(fd, fmt[i]); + }, + } + } +} +``` + +- [ ] **Step 2: Verify** + +Run: `zig fmt --check src/kernel/user/lib/uprintf.zig` +Expected: PASS. + +- [ ] **Step 3: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/user/lib/uprintf.zig +git commit -m "feat(user/lib): add uprintf.zig — minimal printf(fd, fmt, args)" +``` + +--- + +### Task 24: Add `addUserBinary` helper in `build.zig` + +**Files:** +- Modify: `build.zig` (add a helper that builds a user binary by linking start.S + usys.S + ulib + uprintf + main.zig against `user_linker.ld`) + +**Why this task here:** every userland binary in Tasks 25–31 invokes this helper. Without it, each binary needs ~30 lines of build wiring; with it, each binary is one line. + +- [ ] **Step 1: Find a place in `build.zig`** + +`build.zig` is large (~1100 lines). Find the section that builds user programs (likely around the existing `kernel-fs-init` target). The new helper goes right before the FIRST kernel/user build call so it's defined before use. + +- [ ] **Step 2: Add the helper** + +```zig +/// Build a user binary by linking start.S + usys.S + ulib.zig + uprintf.zig + +/// the binary's main.zig against user_linker.ld. Returns the install step +/// path so the fs-img builder can find the .elf. +fn addUserBinary( + b: *std.Build, + name: []const u8, + main_src: []const u8, + rv_target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +) *std.Build.Step.Compile { + const exe = b.addExecutable(.{ + .name = name, + .root_source_file = b.path(main_src), + .target = rv_target, + .optimize = optimize, + .linkage = .static, + }); + exe.setLinkerScript(b.path("src/kernel/user/user_linker.ld")); + exe.entry = .{ .symbol_name = "_start" }; + exe.bundle_compiler_rt = false; + + // Link in the stdlib assembly + Zig modules. + exe.addAssemblyFile(b.path("src/kernel/user/lib/start.S")); + exe.addAssemblyFile(b.path("src/kernel/user/lib/usys.S")); + + // ulib + uprintf are imported by main.zig directly via @import, + // so they don't need explicit module additions here — the Zig compiler + // resolves them from the source tree. (If the build later moves to + // a Module-based layout, register them here.) + + const install = b.addInstallArtifact(exe, .{ + .dest_dir = .{ .override = .{ .custom = "userland/bin" } }, + }); + b.getInstallStep().dependOn(&install.step); + return exe; +} +``` + +- [ ] **Step 3: Test the helper with a dummy build (skip — we'll exercise it in Task 25 with `init_shell.zig`)** + +Run: `zig fmt --check build.zig` +Expected: PASS. + +Run: `zig build kernel-fs` +Expected: PASS — the helper is defined but unused. + +- [ ] **Step 4: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add build.zig +git commit -m "build: add addUserBinary helper (links start.S + usys.S + ulib + main)" +``` + +--- + +### Task 25: Add `src/kernel/user/init_shell.zig` + build target + +**Files:** +- Create: `src/kernel/user/init_shell.zig` (loops `fork → exec("/bin/sh", argv) → wait`) +- Modify: `build.zig` (add `kernel-init-shell` target via `addUserBinary`) + +**Why this task here:** the simplest binary that exercises the full stdlib (uses fork, exec, wait — all from usys.S). Validates `addUserBinary` works end-to-end before we write more complex binaries. + +- [ ] **Step 1: Create `init_shell.zig`** + +```zig +// src/kernel/user/init_shell.zig — Phase 3.E /bin/init replacement. +// +// Loops forever: fork, exec /bin/sh, wait. If sh exits cleanly, restart +// it with a banner so the user knows. If exec fails (no /bin/sh), exit +// with status 127 — the kernel's halt path will catch it via the e2e +// harness. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + _ = argc; + _ = argv; + + while (true) { + const pid = ulib.fork(); + if (pid < 0) { + uprintf.printf(2, "init: fork failed\n", &.{}); + ulib.exit(127); + } + if (pid == 0) { + // Child: exec /bin/sh. + const sh_path: [*:0]const u8 = "/bin/sh"; + const sh_argv: [2]?[*:0]const u8 = .{ sh_path, null }; + _ = ulib.exec(sh_path, &sh_argv); + // exec returned — failure. + uprintf.printf(2, "init: exec /bin/sh failed\n", &.{}); + ulib.exit(127); + } + // Parent: wait for child. + var status: i32 = 0; + const reaped = ulib.wait(&status); + uprintf.printf(1, "[init] sh (pid %d) exited %d; restarting\n", &.{ + .{ .i = reaped }, + .{ .i = status }, + }); + } +} +``` + +- [ ] **Step 2: Wire `kernel-init-shell` build target** + +In `build.zig`, find the existing `kernel-fs-init` target setup (it builds `fs_init.elf`). Right after it, add: + +```zig +const init_shell_exe = addUserBinary( + b, + "init_shell", + "src/kernel/user/init_shell.zig", + rv_target, + optimize, +); +const kernel_init_shell_step = b.step("kernel-init-shell", "Build init_shell.elf (Phase 3.E /bin/init)"); +kernel_init_shell_step.dependOn(&b.addInstallArtifact(init_shell_exe, .{ + .dest_dir = .{ .override = .{ .custom = "userland/bin" } }, +}).step); +``` + +(Adjust `rv_target` and `optimize` to match the variable names used in the surrounding code.) + +- [ ] **Step 3: Build it** + +Run: `zig build kernel-init-shell` +Expected: PASS — produces `zig-out/userland/bin/init_shell.elf`. + +Run: `ls -la zig-out/userland/bin/init_shell.elf` +Expected: file exists. + +Run: `file zig-out/userland/bin/init_shell.elf` (or your platform's equivalent) +Expected: shows it as an ELF32 RV32 executable. + +- [ ] **Step 4: Verify** + +Run: `zig fmt --check src/kernel/user/init_shell.zig build.zig` +Expected: PASS. + +- [ ] **Step 5: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/kernel/user/init_shell.zig build.zig +git commit -m "feat(user): add init_shell.zig (loops fork-exec-sh-wait) + build target" +``` + +--- + +### Task 26: Add `src/kernel/user/echo.zig` + build target + +**Files:** +- Create: `src/kernel/user/echo.zig` (~25 LoC) +- Modify: `build.zig` (`kernel-echo` target) + +**Why this task here:** tiniest non-trivial binary. Exercises argv access + write. Smoke-tests the full toolchain in isolation. + +- [ ] **Step 1: Create `echo.zig`** + +```zig +// src/kernel/user/echo.zig — Phase 3.E echo utility. +// +// Writes argv[1..] joined by spaces, then a newline. + +const ulib = @import("lib/ulib.zig"); + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + var i: u32 = 1; + while (i < argc) : (i += 1) { + const arg = argv[i]; + const len = ulib.strlen(arg); + _ = ulib.write(1, @ptrCast(arg), len); + if (i + 1 < argc) { + const sp: [1]u8 = .{' '}; + _ = ulib.write(1, &sp, 1); + } + } + const nl: [1]u8 = .{'\n'}; + _ = ulib.write(1, &nl, 1); + return 0; +} +``` + +- [ ] **Step 2: Wire build target** + +In `build.zig`, after the `kernel-init-shell` block from Task 25: + +```zig +const echo_exe = addUserBinary(b, "echo", "src/kernel/user/echo.zig", rv_target, optimize); +const kernel_echo_step = b.step("kernel-echo", "Build echo.elf (Phase 3.E)"); +kernel_echo_step.dependOn(&b.addInstallArtifact(echo_exe, .{ + .dest_dir = .{ .override = .{ .custom = "userland/bin" } }, +}).step); +``` + +- [ ] **Step 3: Build it** + +Run: `zig build kernel-echo` +Expected: PASS — produces `zig-out/userland/bin/echo.elf`. + +- [ ] **Step 4: Verify** + +Run: `zig fmt --check src/kernel/user/echo.zig build.zig` +Expected: PASS. + +- [ ] **Step 5: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/kernel/user/echo.zig build.zig +git commit -m "feat(user): add echo.zig + build target" +``` + +--- + +### Task 27: Add `src/kernel/user/cat.zig` + build target + +**Files:** +- Create: `src/kernel/user/cat.zig` (~40 LoC) +- Modify: `build.zig` (`kernel-cat` target) + +**Why this task here:** validates the file-read path through `read(fd, buf, n)`. Used by the milestone session's `cat /tmp/x`. + +- [ ] **Step 1: Create `cat.zig`** + +```zig +// src/kernel/user/cat.zig — Phase 3.E cat utility. +// +// With no args: copy fd 0 → fd 1 until EOF. +// With args: open each, copy contents to fd 1, close. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +const BUF_SIZE: u32 = 512; +var buf: [BUF_SIZE]u8 = undefined; + +fn copyFd(fd: u32) void { + while (true) { + const got = ulib.read(fd, &buf, BUF_SIZE); + if (got <= 0) break; + var written: u32 = 0; + while (written < @as(u32, @intCast(got))) { + const w = ulib.write(1, buf[written..].ptr, @as(u32, @intCast(got)) - written); + if (w <= 0) break; + written += @intCast(w); + } + } +} + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + if (argc < 2) { + copyFd(0); + return 0; + } + + var i: u32 = 1; + while (i < argc) : (i += 1) { + const fd = ulib.openat(0, argv[i], ulib.O_RDONLY); + if (fd < 0) { + uprintf.printf(2, "cat: cannot open %s\n", &.{.{ .s = argv[i] }}); + continue; + } + copyFd(@intCast(fd)); + _ = ulib.close(@intCast(fd)); + } + return 0; +} +``` + +- [ ] **Step 2: Wire build target** + +In `build.zig`: + +```zig +const cat_exe = addUserBinary(b, "cat", "src/kernel/user/cat.zig", rv_target, optimize); +const kernel_cat_step = b.step("kernel-cat", "Build cat.elf (Phase 3.E)"); +kernel_cat_step.dependOn(&b.addInstallArtifact(cat_exe, .{ + .dest_dir = .{ .override = .{ .custom = "userland/bin" } }, +}).step); +``` + +- [ ] **Step 3: Build + verify + regression** + +Run: `zig build kernel-cat` +Expected: PASS. + +Run: `zig fmt --check src/kernel/user/cat.zig` +Expected: PASS. + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/user/cat.zig build.zig +git commit -m "feat(user): add cat.zig + build target" +``` + +--- + +### Task 28: Add `src/kernel/user/ls.zig` + build target + +**Files:** +- Create: `src/kernel/user/ls.zig` (~70 LoC) +- Modify: `build.zig` (`kernel-ls` target) + +**Why this task here:** validates fstat + directory read (DirEntry parsing). Used by the milestone session's `ls /bin`. + +- [ ] **Step 1: Create `ls.zig`** + +```zig +// src/kernel/user/ls.zig — Phase 3.E ls utility. +// +// With no args: list current directory. +// With args: for each path, fstat to determine type: +// - Dir: read DirEntry records, print each non-zero name. +// - File: print the path itself + size. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +// Must match kernel fs/layout.zig: u16 inum + 14-byte name = 16 B total. +const DIR_NAME_LEN: u32 = 14; +const DirEntry = extern struct { + inum: u16, + name: [DIR_NAME_LEN]u8, +}; + +fn printName(name: *const [DIR_NAME_LEN]u8) void { + var n: u32 = 0; + while (n < DIR_NAME_LEN and name[n] != 0) : (n += 1) {} + _ = ulib.write(1, name, n); + const nl: [1]u8 = .{'\n'}; + _ = ulib.write(1, &nl, 1); +} + +fn lsPath(path: [*:0]const u8) void { + const fd = ulib.openat(0, path, ulib.O_RDONLY); + if (fd < 0) { + uprintf.printf(2, "ls: cannot open %s\n", &.{.{ .s = path }}); + return; + } + defer _ = ulib.close(@intCast(fd)); + + var st: ulib.Stat = .{ .type = 0, .size = 0 }; + if (ulib.fstat(@intCast(fd), &st) < 0) { + uprintf.printf(2, "ls: cannot stat %s\n", &.{.{ .s = path }}); + return; + } + + if (st.type == ulib.STAT_FILE) { + // Print the path itself; ls(1) on Linux prints just the basename + // when given a file, but our 1-arg ls just echoes whatever the + // user passed. + uprintf.printf(1, "%s %u\n", &.{ .{ .s = path }, .{ .u = st.size } }); + return; + } + + if (st.type != ulib.STAT_DIR) { + uprintf.printf(2, "ls: %s: unknown type\n", &.{.{ .s = path }}); + return; + } + + var de: DirEntry = .{ .inum = 0, .name = [_]u8{0} ** DIR_NAME_LEN }; + while (true) { + const got = ulib.read(@intCast(fd), @ptrCast(&de), @sizeOf(DirEntry)); + if (got != @sizeOf(DirEntry)) break; + if (de.inum == 0) continue; + printName(&de.name); + } +} + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + if (argc < 2) { + lsPath("."); + return 0; + } + var i: u32 = 1; + while (i < argc) : (i += 1) { + lsPath(argv[i]); + } + return 0; +} +``` + +- [ ] **Step 2: Wire build target** + +```zig +const ls_exe = addUserBinary(b, "ls", "src/kernel/user/ls.zig", rv_target, optimize); +const kernel_ls_step = b.step("kernel-ls", "Build ls.elf (Phase 3.E)"); +kernel_ls_step.dependOn(&b.addInstallArtifact(ls_exe, .{ + .dest_dir = .{ .override = .{ .custom = "userland/bin" } }, +}).step); +``` + +- [ ] **Step 3: Build + verify + regression** + +Run: `zig build kernel-ls` +Expected: PASS. + +Run: `zig fmt --check src/kernel/user/ls.zig` +Expected: PASS. + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/user/ls.zig build.zig +git commit -m "feat(user): add ls.zig + build target" +``` + +--- + +### Task 29: Add `src/kernel/user/mkdir.zig` + build target + +**Files:** +- Create: `src/kernel/user/mkdir.zig` (~25 LoC) +- Modify: `build.zig` (`kernel-mkdir` target) + +**Why this task here:** the milestone session doesn't use `mkdir`, but the spec lists it as a 3.E binary. Lands as a one-line wrapper around `mkdirat(0, argv[1])`. + +- [ ] **Step 1: Create `mkdir.zig`** + +```zig +// src/kernel/user/mkdir.zig — Phase 3.E mkdir utility. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + if (argc < 2) { + uprintf.printf(2, "usage: mkdir \n", &.{}); + return 1; + } + if (ulib.mkdirat(0, argv[1]) < 0) { + uprintf.printf(2, "mkdir: cannot create %s\n", &.{.{ .s = argv[1] }}); + return 1; + } + return 0; +} +``` + +- [ ] **Step 2: Wire build target** + +```zig +const mkdir_exe = addUserBinary(b, "mkdir", "src/kernel/user/mkdir.zig", rv_target, optimize); +const kernel_mkdir_step = b.step("kernel-mkdir", "Build mkdir.elf (Phase 3.E)"); +kernel_mkdir_step.dependOn(&b.addInstallArtifact(mkdir_exe, .{ + .dest_dir = .{ .override = .{ .custom = "userland/bin" } }, +}).step); +``` + +- [ ] **Step 3: Build + verify + regression** + +Run: `zig build kernel-mkdir` +Expected: PASS. + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/user/mkdir.zig build.zig +git commit -m "feat(user): add mkdir.zig + build target" +``` + +--- + +### Task 30: Add `src/kernel/user/rm.zig` + build target + +**Files:** +- Create: `src/kernel/user/rm.zig` (~25 LoC) +- Modify: `build.zig` (`kernel-rm` target) + +**Why this task here:** the milestone session does `rm /tmp/x`. One-line wrapper around `unlinkat(0, argv[1], 0)`. + +- [ ] **Step 1: Create `rm.zig`** + +```zig +// src/kernel/user/rm.zig — Phase 3.E rm utility. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + if (argc < 2) { + uprintf.printf(2, "usage: rm \n", &.{}); + return 1; + } + if (ulib.unlinkat(0, argv[1], 0) < 0) { + uprintf.printf(2, "rm: cannot remove %s\n", &.{.{ .s = argv[1] }}); + return 1; + } + return 0; +} +``` + +- [ ] **Step 2: Wire build target** + +```zig +const rm_exe = addUserBinary(b, "rm", "src/kernel/user/rm.zig", rv_target, optimize); +const kernel_rm_step = b.step("kernel-rm", "Build rm.elf (Phase 3.E)"); +kernel_rm_step.dependOn(&b.addInstallArtifact(rm_exe, .{ + .dest_dir = .{ .override = .{ .custom = "userland/bin" } }, +}).step); +``` + +- [ ] **Step 3: Build + verify + regression** + +Run: `zig build kernel-rm` +Expected: PASS. + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/kernel/user/rm.zig build.zig +git commit -m "feat(user): add rm.zig + build target" +``` + +--- + +### Task 31: Add `src/kernel/user/sh.zig` + build target + +**Files:** +- Create: `src/kernel/user/sh.zig` (~350 LoC: read line, tokenize, builtins, fork+exec, redirects) +- Modify: `build.zig` (`kernel-sh` target) + +**Why this task here:** the keystone of Plan 3.E. Pulls together every prior piece — console fd, file syscalls, fork/exec/wait, set_fg_pid. After this task the user binaries are complete; only mkfs + shell-fs-img + e2e remain. + +**Sequencing tip:** review the `sh.zig` source carefully against the spec's "Userland" / `sh` row. If anything's unclear, that's a sign to break into a sub-task — but the source below is meant to be paste-and-build. + +- [ ] **Step 1: Create `sh.zig`** + +```zig +// src/kernel/user/sh.zig — Phase 3.E shell. +// +// Loop: +// - Print "$ " prompt to fd 1. +// - Read a line from fd 0 (terminated by \n thanks to the kernel +// console line discipline). +// - Tokenize on whitespace; recognize `<`, `>`, `>>` as redirect tokens. +// - If first token is `cd` / `pwd` / `exit`, handle inline. +// - Else: fork; in child, apply redirects (close target fd, open file at +// same fd via openat which returns lowest free fd — we close-then-open +// to land at the target fd); exec the binary. In parent, set_fg_pid +// (child), wait, set_fg_pid(0). +// +// Exec path resolution: if argv[0] starts with "/", use as-is. Else prepend +// "/bin/" so `ls` becomes `/bin/ls`. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +const LINE_MAX: u32 = 256; +const MAX_TOKENS: u32 = 32; +const PATH_MAX: u32 = 256; + +var line_buf: [LINE_MAX]u8 = undefined; +var argv_storage: [MAX_TOKENS][PATH_MAX]u8 = undefined; +var argv_ptrs: [MAX_TOKENS + 1]?[*:0]const u8 = undefined; +var path_buf: [PATH_MAX]u8 = undefined; + +const RedirectKind = enum { None, In, Out, Append }; + +const ParsedCmd = struct { + argc: u32, + redir_kind: RedirectKind, + redir_target: ?[*:0]const u8, +}; + +fn isSpace(c: u8) bool { + return c == ' ' or c == '\t' or c == '\n'; +} + +fn isRedirChar(c: u8) bool { + return c == '<' or c == '>'; +} + +/// Tokenize `line` (NUL-terminated, length n) into argv_storage + parse a +/// single redirect (if any). Returns the parsed result. argc is the count +/// of "real" argv tokens (not including the redirect file). +fn parseLine(line: [*]const u8, n: u32) ParsedCmd { + var i: u32 = 0; + var argc: u32 = 0; + var result: ParsedCmd = .{ .argc = 0, .redir_kind = .None, .redir_target = null }; + + while (i < n) { + // Skip whitespace. + while (i < n and isSpace(line[i])) : (i += 1) {} + if (i >= n) break; + + // Redirect? + if (isRedirChar(line[i])) { + var kind: RedirectKind = .Out; + if (line[i] == '<') kind = .In; + i += 1; + if (kind == .Out and i < n and line[i] == '>') { + kind = .Append; + i += 1; + } + // Skip whitespace, then capture target. + while (i < n and isSpace(line[i])) : (i += 1) {} + const target_start = i; + while (i < n and !isSpace(line[i]) and !isRedirChar(line[i])) : (i += 1) {} + const target_len = i - target_start; + if (target_len == 0 or target_len >= PATH_MAX) { + uprintf.printf(2, "sh: missing redirect target\n", &.{}); + return .{ .argc = 0, .redir_kind = .None, .redir_target = null }; + } + // Stash target into the last argv slot (we won't pass it to exec). + const slot = MAX_TOKENS - 1; + var k: u32 = 0; + while (k < target_len) : (k += 1) argv_storage[slot][k] = line[target_start + k]; + argv_storage[slot][target_len] = 0; + result.redir_kind = kind; + result.redir_target = @ptrCast(&argv_storage[slot][0]); + continue; + } + + // Plain token. + if (argc >= MAX_TOKENS - 1) { + uprintf.printf(2, "sh: too many args\n", &.{}); + return .{ .argc = 0, .redir_kind = .None, .redir_target = null }; + } + const start = i; + while (i < n and !isSpace(line[i]) and !isRedirChar(line[i])) : (i += 1) {} + const tok_len = i - start; + if (tok_len >= PATH_MAX) { + uprintf.printf(2, "sh: token too long\n", &.{}); + return .{ .argc = 0, .redir_kind = .None, .redir_target = null }; + } + var k: u32 = 0; + while (k < tok_len) : (k += 1) argv_storage[argc][k] = line[start + k]; + argv_storage[argc][tok_len] = 0; + argv_ptrs[argc] = @ptrCast(&argv_storage[argc][0]); + argc += 1; + } + + argv_ptrs[argc] = null; + result.argc = argc; + return result; +} + +/// Resolve the binary path: if argv[0] starts with "/", use as-is; else +/// prepend "/bin/". Writes the result into `path_buf` (NUL-terminated). +fn resolveBin(name: [*:0]const u8) [*:0]const u8 { + if (name[0] == '/') return name; + var i: u32 = 0; + const prefix = "/bin/"; + while (i < prefix.len) : (i += 1) path_buf[i] = prefix[i]; + var j: u32 = 0; + while (name[j] != 0 and i + j + 1 < PATH_MAX) : (j += 1) path_buf[i + j] = name[j]; + path_buf[i + j] = 0; + return @ptrCast(&path_buf[0]); +} + +fn doRedirect(kind: RedirectKind, target: [*:0]const u8) bool { + switch (kind) { + .None => return true, + .In => { + _ = ulib.close(0); + const fd = ulib.openat(0, target, ulib.O_RDONLY); + if (fd != 0) { + uprintf.printf(2, "sh: redir < %s failed\n", &.{.{ .s = target }}); + return false; + } + return true; + }, + .Out => { + _ = ulib.close(1); + const fd = ulib.openat(0, target, ulib.O_WRONLY | ulib.O_CREAT | ulib.O_TRUNC); + if (fd != 1) { + uprintf.printf(2, "sh: redir > %s failed\n", &.{.{ .s = target }}); + return false; + } + return true; + }, + .Append => { + _ = ulib.close(1); + const fd = ulib.openat(0, target, ulib.O_WRONLY | ulib.O_CREAT | ulib.O_APPEND); + if (fd != 1) { + uprintf.printf(2, "sh: redir >> %s failed\n", &.{.{ .s = target }}); + return false; + } + return true; + }, + } +} + +fn handleBuiltin(parsed: *const ParsedCmd) bool { + if (parsed.argc == 0) return false; + const cmd = argv_ptrs[0].?; + + if (ulib.strcmp(cmd, "exit") == 0) { + ulib.exit(0); + } + if (ulib.strcmp(cmd, "cd") == 0) { + if (parsed.argc < 2) { + uprintf.printf(2, "cd: missing arg\n", &.{}); + return true; + } + if (ulib.chdir(argv_ptrs[1].?) < 0) { + uprintf.printf(2, "cd: %s: no such directory\n", &.{.{ .s = argv_ptrs[1].? }}); + } + return true; + } + if (ulib.strcmp(cmd, "pwd") == 0) { + var cwd_buf: [PATH_MAX]u8 = undefined; + const len = ulib.getcwd(&cwd_buf, PATH_MAX); + if (len < 0) { + uprintf.printf(2, "pwd: getcwd failed\n", &.{}); + return true; + } + _ = ulib.write(1, &cwd_buf, @intCast(len)); + const nl: [1]u8 = .{'\n'}; + _ = ulib.write(1, &nl, 1); + return true; + } + return false; +} + +fn runCommand(parsed: *const ParsedCmd) void { + if (parsed.argc == 0) return; + if (handleBuiltin(parsed)) return; + + const pid = ulib.fork(); + if (pid < 0) { + uprintf.printf(2, "sh: fork failed\n", &.{}); + return; + } + if (pid == 0) { + // Child. + if (parsed.redir_kind != .None) { + if (!doRedirect(parsed.redir_kind, parsed.redir_target.?)) ulib.exit(1); + } + const path = resolveBin(argv_ptrs[0].?); + _ = ulib.exec(path, &argv_ptrs); + uprintf.printf(2, "sh: exec %s failed\n", &.{.{ .s = path }}); + ulib.exit(127); + } + // Parent. + _ = ulib.set_fg_pid(@intCast(pid)); + var status: i32 = 0; + _ = ulib.wait(&status); + _ = ulib.set_fg_pid(0); +} + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + _ = argc; + _ = argv; + + while (true) { + // Prompt. + const prompt: [2]u8 = .{ '$', ' ' }; + _ = ulib.write(1, &prompt, 2); + + // Read a line. + const got = ulib.getline(0, &line_buf, LINE_MAX); + if (got <= 0) { + // EOF on stdin: bail. + _ = ulib.write(1, "\n", 1); + return 0; + } + + const n: u32 = @intCast(got); + // Skip blank lines. + var blank = true; + var i: u32 = 0; + while (i < n) : (i += 1) { + if (!isSpace(line_buf[i])) { + blank = false; + break; + } + } + if (blank) continue; + + const parsed = parseLine(&line_buf, n); + runCommand(&parsed); + } +} +``` + +- [ ] **Step 2: Wire build target** + +In `build.zig`: + +```zig +const sh_exe = addUserBinary(b, "sh", "src/kernel/user/sh.zig", rv_target, optimize); +const kernel_sh_step = b.step("kernel-sh", "Build sh.elf (Phase 3.E)"); +kernel_sh_step.dependOn(&b.addInstallArtifact(sh_exe, .{ + .dest_dir = .{ .override = .{ .custom = "userland/bin" } }, +}).step); +``` + +- [ ] **Step 3: Build it** + +Run: `zig build kernel-sh` +Expected: PASS. + +Run: `ls -la zig-out/userland/bin/sh.elf` +Expected: file exists, ~20-30 KB ELF32 RV32. + +- [ ] **Step 4: Verify** + +Run: `zig fmt --check src/kernel/user/sh.zig` +Expected: PASS. + +- [ ] **Step 5: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/kernel/user/sh.zig build.zig +git commit -m "feat(user): add sh.zig — line/token/redirect/builtins/fork+exec + build" +``` + +--- + +### Task 32: Update `mkfs.zig` (skip dot-files, recurse into empty dirs, `--init` flag) + +**Files:** +- Modify: `src/kernel/mkfs.zig` (three changes — see Why) + +**Why this task here:** the new `shell-fs-img` target needs three things mkfs doesn't currently do: (1) install an empty `/tmp/` directory (carrying a `.gitkeep` placeholder we want to skip); (2) install `init_shell.elf` at `/bin/init` instead of the default `fs_init.elf`; (3) sort directory entries deterministically so `ls /bin` output is stable for the e2e test. + +- [ ] **Step 1: Add `--init` flag handling** + +In `src/kernel/mkfs.zig`, find the existing CLI argument parser. Where it parses `--root`, `--bin`, `--out`, add a new arm for `--init`: + +```zig +} else if (std.mem.eql(u8, args[i], "--init")) { + i += 1; + init_path = args[i]; +} +``` + +Declare `var init_path: ?[]const u8 = null;` near the other arg vars. Pass it through to wherever the image builder installs `/bin/init`. Where the existing code installs `fs_init.elf` at `/bin/init`, replace with: + +```zig +const init_source: []const u8 = if (init_path) |p| p else default_init_path; +// where `default_init_path` is the existing 3.D path to fs_init.elf. +``` + +- [ ] **Step 2: Skip dot-files in directory walks** + +Find the directory walker (likely uses `std.fs.Dir.iterate` or `std.fs.Dir.walk`). In the per-entry loop, add at the top: + +```zig +if (entry.name.len > 0 and entry.name[0] == '.') continue; +``` + +This rejects `.gitkeep`, `.DS_Store`, etc. + +- [ ] **Step 3: Recurse into empty directories** + +If the walker already iterates subdirectories, verify it creates a Dir inode + `.` / `..` entries for empty subdirs. If it skips them (e.g., only walks files), add: + +```zig +// For each subdirectory entry (even if empty), create a Dir inode in the +// image. The recursion that follows will fill it (or leave it empty). +``` + +The exact code depends on how `mkfs.zig` is structured today. Open `src/kernel/mkfs.zig` and inspect the recursion. If empty dirs are silently dropped, add a branch in the walker that calls `appendDirEntry(parent, entry.name, new_dir_inum)` for every directory regardless of whether it has children. + +- [ ] **Step 4: Sort directory entries deterministically** + +Find the directory walker. Before iterating entries, collect all entry names into a `std.ArrayList([]const u8)`, sort with `std.mem.sort([]const u8, names.items, {}, std.ascii.lessThanIgnoreCase)`, then iterate the sorted list. This makes `ls /bin` output reproducible for the e2e test. + +(Skipping if mkfs already sorts. Inspect `src/kernel/mkfs.zig` to confirm.) + +- [ ] **Step 5: Verify mkfs still builds** + +Run: `zig build mkfs` +Expected: PASS. + +Run: `zig build fs-img` +Expected: PASS — produces `zig-out/fs.img` (still uses `fs_init.elf` by default). + +- [ ] **Step 6: Verify the existing fs.img still has motd** + +Run: `zig build e2e-fs` +Expected: PASS — 3.D's image is regenerated and behaves the same. + +- [ ] **Step 7: Commit** + +```bash +git add src/kernel/mkfs.zig +git commit -m "build(mkfs): skip dot-files, recurse empty dirs, --init flag, sort entries" +``` + +--- + +### Task 33: Add `shell-fs-img` build target + staging dir + +**Files:** +- Create: `src/kernel/userland/shell-fs/etc/motd` (`"hello from phase 3\n"`, 19 bytes — same content as 3.D) +- Create: `src/kernel/userland/shell-fs/tmp/.gitkeep` (empty file; mkfs skips it; carrier for the empty `tmp/` dir in git) +- Modify: `build.zig` (add `shell-fs-img` step that runs mkfs against the new staging dir + the new binaries) + +**Why this task here:** the e2e-shell test (Task 34) needs a disk image that has init_shell.elf at `/bin/init` plus all the utility binaries. Lands as a parallel target alongside `fs-img`. + +- [ ] **Step 1: Create staging dir contents** + +```bash +mkdir -p src/kernel/userland/shell-fs/etc +mkdir -p src/kernel/userland/shell-fs/tmp +echo -n "hello from phase 3" > src/kernel/userland/shell-fs/etc/motd +printf "\n" >> src/kernel/userland/shell-fs/etc/motd +touch src/kernel/userland/shell-fs/tmp/.gitkeep +``` + +(That double-step on motd matches the exact byte sequence — 19 bytes including the trailing newline.) + +Verify: + +```bash +wc -c src/kernel/userland/shell-fs/etc/motd +``` + +Expected: `19`. + +```bash +ls -la src/kernel/userland/shell-fs/tmp/ +``` + +Expected: `.gitkeep` is present and empty. + +- [ ] **Step 2: Wire `shell-fs-img` build target** + +In `build.zig`, find the existing `fs-img` target. Below it, add: + +```zig +// Phase 3.E: build shell-fs.img — installs init_shell as /bin/init + all +// utilities (sh, ls, cat, echo, mkdir, rm) into /bin/, mounts the +// shell-fs/ staging tree (etc/motd + tmp/). +const shell_fs_img = b.addRunArtifact(mkfs_exe); // mkfs_exe is the existing host tool +shell_fs_img.addArg("--root"); +shell_fs_img.addArg(b.path("src/kernel/userland/shell-fs/").getPath(b)); +shell_fs_img.addArg("--bin"); +shell_fs_img.addArg(b.fmt("{s}/userland/bin/", .{b.install_path})); +shell_fs_img.addArg("--init"); +shell_fs_img.addArg(b.fmt("{s}/userland/bin/init_shell.elf", .{b.install_path})); +shell_fs_img.addArg("--out"); +shell_fs_img.addArg(b.fmt("{s}/shell-fs.img", .{b.install_path})); +// Depend on every userland binary so they're built first. +shell_fs_img.step.dependOn(&kernel_init_shell_step.*); // see Task 25 +shell_fs_img.step.dependOn(&kernel_sh_step.*); // Task 31 +shell_fs_img.step.dependOn(&kernel_ls_step.*); // Task 28 +shell_fs_img.step.dependOn(&kernel_cat_step.*); // Task 27 +shell_fs_img.step.dependOn(&kernel_echo_step.*); // Task 26 +shell_fs_img.step.dependOn(&kernel_mkdir_step.*); // Task 29 +shell_fs_img.step.dependOn(&kernel_rm_step.*); // Task 30 + +const shell_fs_img_step = b.step("shell-fs-img", "Build shell-fs.img with all Phase 3.E binaries"); +shell_fs_img_step.dependOn(&shell_fs_img.step); +``` + +(Adapt variable names and path-building style to match the surrounding code in `build.zig`. The above is a sketch — the exact API differs in 0.16.) + +- [ ] **Step 3: Build the image** + +Run: `zig build shell-fs-img` +Expected: PASS — produces `zig-out/shell-fs.img` (4 MB). + +Run: `ls -la zig-out/shell-fs.img` +Expected: file exists, exactly 4194304 bytes. + +- [ ] **Step 4: Inspect (optional but useful for confidence)** + +Run: `head -c 32 zig-out/shell-fs.img | xxd` +Expected: shows zeros (block 0 is the boot sector reserved zone). + +- [ ] **Step 5: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork` +Expected: PASS — 3.D's `fs.img` is regenerated normally; `shell-fs.img` is parallel. + +- [ ] **Step 6: Commit** + +```bash +git add src/kernel/userland/shell-fs/ build.zig +git commit -m "build: add shell-fs-img target + shell-fs/ staging (motd + empty tmp)" +``` + +--- + +### Task 34: Add `tests/e2e/shell.zig` + `shell_input.txt` + `e2e-shell` step + +**Files:** +- Create: `tests/e2e/shell_input.txt` (the milestone session, verbatim) +- Create: `tests/e2e/shell.zig` (host harness) +- Modify: `build.zig` (`e2e-shell` step) + +**Why this task here:** the milestone. After this task lands and passes, Plan 3.E is functionally complete. Task 35 is README + final regression sweep. + +- [ ] **Step 1: Create `tests/e2e/shell_input.txt`** + +Exact content (every line ends with `\n`, including the final `exit`): + +``` +ls /bin +echo hi > /tmp/x +cat /tmp/x +rm /tmp/x +exit +``` + +Verify byte count: + +```bash +wc -c tests/e2e/shell_input.txt +``` + +Expected: `49` (5 lines: 8 + 18 + 11 + 10 + 5 = 52? recount). Let's verify by exact byte: +- `ls /bin\n` = 8 +- `echo hi > /tmp/x\n` = 17 +- `cat /tmp/x\n` = 11 +- `rm /tmp/x\n` = 10 +- `exit\n` = 5 +- Total: 51 bytes. + +(Run `wc -c` to confirm — adjust the assertion if your editor adds a different newline.) + +- [ ] **Step 2: Create `tests/e2e/shell.zig`** + +Pattern: copy `tests/e2e/fs.zig` and adapt. Key changes: +- Use `--input tests/e2e/shell_input.txt` argument. +- Use `--disk zig-out/shell-fs.img`. +- Assert stdout contains specific landmarks. + +```zig +// tests/e2e/shell.zig — Phase 3.E e2e: scripted shell session. + +const std = @import("std"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + const argv = [_][]const u8{ + "zig-out/bin/ccc", + "--input", + "tests/e2e/shell_input.txt", + "--disk", + "zig-out/shell-fs.img", + "zig-out/bin/kernel-fs.elf", + }; + + var child = std.process.Child.init(&argv, alloc); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + + var stdout_buf = std.ArrayList(u8).init(alloc); + defer stdout_buf.deinit(); + var stderr_buf = std.ArrayList(u8).init(alloc); + defer stderr_buf.deinit(); + try child.collectOutput(&stdout_buf, &stderr_buf, 1024 * 1024); + + const term = try child.wait(); + if (term != .Exited or term.Exited != 0) { + std.debug.print("ccc exited abnormally: {any}\n", .{term}); + std.debug.print("stdout:\n{s}\n", .{stdout_buf.items}); + std.debug.print("stderr:\n{s}\n", .{stderr_buf.items}); + return error.AbnormalExit; + } + + const out = stdout_buf.items; + + // Landmark assertions — each must appear in order, but we do the + // simpler containment check (looser; tolerant of init's restart banner). + const wanted = [_][]const u8{ + "$ ls /bin", + "sh", + "$ echo hi > /tmp/x", + "$ cat /tmp/x", + "hi\n", + "$ rm /tmp/x", + "$ exit", + }; + for (wanted) |w| { + if (std.mem.indexOf(u8, out, w) == null) { + std.debug.print("missing landmark in stdout: {s}\n", .{w}); + std.debug.print("full stdout:\n{s}\n", .{out}); + return error.MissingLandmark; + } + } +} +``` + +- [ ] **Step 3: Wire `e2e-shell` build step** + +In `build.zig`, find the `e2e-fs` step. Below it, add: + +```zig +const shell_e2e_exe = b.addExecutable(.{ + .name = "e2e-shell", + .root_source_file = b.path("tests/e2e/shell.zig"), + .target = b.host, + .optimize = .Debug, +}); +const shell_e2e_run = b.addRunArtifact(shell_e2e_exe); +shell_e2e_run.step.dependOn(b.getInstallStep()); // ensure ccc + kernel-fs.elf are installed +shell_e2e_run.step.dependOn(&shell_fs_img_step.*); +const e2e_shell_step = b.step("e2e-shell", "Run the Phase 3.E shell e2e test"); +e2e_shell_step.dependOn(&shell_e2e_run.step); +``` + +(Adjust dependency wiring to match how the existing `e2e-fs` step is structured.) + +- [ ] **Step 4: Run it!** + +Run: `zig build e2e-shell` +Expected: PASS — stdout shows the milestone session transcript; exit 0. + +If it fails: +- Check `zig-out/shell-fs.img` exists and is 4 MB. +- Manually run `zig-out/bin/ccc --input tests/e2e/shell_input.txt --disk zig-out/shell-fs.img zig-out/bin/kernel-fs.elf` and inspect output. +- Common failures: `init_shell.elf` not found (mkfs `--init` flag wired wrong); shell parse error on `>` redirect (sh.zig token logic); `/tmp` not present (mkfs empty-dir handling); shell prompts but never prints the cat output (line discipline buffering / wakeup race). + +- [ ] **Step 5: Verify** + +Run: `zig fmt --check tests/e2e/shell.zig build.zig` +Expected: PASS. + +- [ ] **Step 6: Regression e2e** + +Run: `zig build e2e-fs && zig build e2e-fork && zig build e2e-multiproc-stub && zig build e2e-plic-block && zig build e2e-snake` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add tests/e2e/shell.zig tests/e2e/shell_input.txt build.zig +git commit -m "feat(e2e): add e2e-shell — scripted ls/echo/cat/rm/exit session" +``` + +--- + +### Task 35: README + final regression sweep + +**Files:** +- Modify: `README.md` (add Phase 3.E entry to Status / Layout / Building tables) + +**Why this task here:** the closing task. Update the headline doc, run the entire test suite, and tag the work as ready for PR. + +- [ ] **Step 1: Update `README.md` Status block** + +Find the existing Phase 3.D status line. Add a parallel line below: + +```markdown +**Phase 3.E — FS write path + console fd + shell + utilities.** Console as fd 0/1/2 with cooked-mode line discipline (echo, backspace, `^U`, `^C`, `^D`, line completion); UART RX wired through PLIC IRQ #10 + uart.isr → console.feedByte. FS write path (`writei`, `bmap` lazy alloc, `iupdate`, `ialloc`, `itrunc`, `iput`-on-zero truncate, `dirlink` real impl, `dirunlink`, `fsops.create` + `fsops.unlink`). New syscalls: `mkdirat` (34), `unlinkat` (35); extensions to `openat` (`O_CREAT`, `O_TRUNC`, `O_APPEND`) and `write` (any fd via `file.write`); `set_fg_pid` + `console_set_mode` + killed-flag check at syscall return. User stdlib (`start.S`, `usys.S`, `ulib.zig`, `uprintf.zig`). Userland: `init`, `sh`, `ls`, `cat`, `echo`, `mkdir`, `rm`. New e2e: `e2e-shell` runs `ls /bin\necho hi > /tmp/x\ncat /tmp/x\nrm /tmp/x\nexit\n`. +``` + +- [ ] **Step 2: Update Layout table** + +Find the existing Layout block. Add new rows for `console.zig`, `fs/fsops.zig`, `user/lib/`, `user/init_shell.zig`, `user/sh.zig`, `user/ls.zig`, `user/cat.zig`, `user/echo.zig`, `user/mkdir.zig`, `user/rm.zig`, `userland/shell-fs/`, `tests/e2e/shell.zig`, `tests/e2e/shell_input.txt`. Keep the table sorted by path. + +- [ ] **Step 3: Update Building table** + +Find the existing Building block. Add rows: + +``` +zig build kernel-init-shell — build init_shell.elf (Phase 3.E /bin/init) +zig build kernel-sh — build sh.elf +zig build kernel-ls — build ls.elf +zig build kernel-cat — build cat.elf +zig build kernel-echo — build echo.elf +zig build kernel-mkdir — build mkdir.elf +zig build kernel-rm — build rm.elf +zig build shell-fs-img — build shell-fs.img (init_shell + utilities) +zig build e2e-shell — run the Phase 3.E shell e2e test +``` + +- [ ] **Step 4: Run the full test sweep** + +Run each in order; all must PASS: + +```bash +zig build test +zig build e2e-shell +zig build e2e-fs +zig build e2e-kernel +zig build e2e-multiproc-stub +zig build e2e-fork +zig build e2e-plic-block +zig build e2e-snake +zig build e2e-hello-elf && zig build e2e && zig build e2e-mul && zig build e2e-trap +zig build riscv-tests +zig build wasm +``` + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: README — add Phase 3.E status, layout, build targets" +``` + +--- + +## Self-review summary + +This plan covers every spec requirement for Plan 3.E: + +- **`write` syscall extended to file fds** — Tasks 8 (`file.write` dispatch) + 10 (`sysWrite` routing) + 12 (`writei`). +- **`mkdirat` syscall** — Task 18 (wired) + Task 16 (`fsops.create` for `.Dir`). +- **`unlinkat` syscall** — Task 19 (wired) + Task 16 (`fsops.unlink`). +- **Console as fd 0/1/2 with line discipline** — Tasks 2–4 (console.zig: skeleton, feedByte, read), Task 5 (uart.isr drain), Task 6 (trap dispatch IRQ #10), Task 9 (kmain installs Console fds), Task 10 (sysWrite/sysRead route through file.{write,read}), Task 8 (file.zig Console handling), Task 7 (sysSetFgPid + sysConsoleSetMode + killed-flag check). +- **User stdlib (`start.S`, `usys.S`, `ulib.zig`, `uprintf.zig`)** — Tasks 20, 21, 22, 23. +- **`addUserBinary` build helper** — Task 24. +- **Userland binaries (`init`, `sh`, `ls`, `cat`, `echo`, `mkdir`, `rm`)** — Tasks 25 (init_shell), 26 (echo), 27 (cat), 28 (ls), 29 (mkdir), 30 (rm), 31 (sh). +- **`mkfs.zig` updated for new binaries + empty `/tmp/`** — Task 32 (skip dot-files, recurse empty dirs, `--init` flag, sort entries). +- **`shell-fs.img` build target** — Task 33 (staging dir + mkfs invocation). +- **Milestone: `e2e-shell` runs `ls /bin\necho hi > /tmp/x\ncat /tmp/x\nrm /tmp/x\nexit\n`** — Task 34. +- **README + final regression sweep** — Task 35. + +Spec items deferred to Plan 3.F (per the spec's own decomposition): +- **`edit` userland binary** (Plan 3.F). +- **`console_set_mode` raw mode actually exercised** (Plan 3.F via the editor; Plan 3.E lands the kernel-side Raw arm but the only test path is cooked). +- **`e2e-persist`** (Plan 3.F: re-run on the same fs.img and observe writes survived). +- **Cursor-moving editor with ANSI escapes + `^S` save + `^X` exit** (Plan 3.F). + +Spec items NOT deferred — verifying coverage: +- **FS write path: `writei`, `bmap` lazy alloc, `iupdate`, `ialloc`, `itrunc`, `iput`-on-zero truncate** — Tasks 11 (iupdate), 12 (writei + bmap.for_write), 13 (ialloc), 14 (itrunc + iput-on-zero). +- **`dirlink` real impl + `dirunlink`** — Task 15. +- **Path resolution `nameiparent`** — already shipped in Plan 3.D (verified during the Plan 3.E pre-survey). +- **`O_CREAT`, `O_TRUNC`, `O_APPEND` on `openat`** — Task 17. +- **kill-flag wired through `^C` path** — Tasks 3 (console.feedByte calls `proc.kill` on `^C`), 7 (killed-flag check at syscall return). +- **`set_fg_pid` + `console_set_mode` syscalls real impls (3.D landed stubs)** — Task 7. + +No placeholders remain. Every code-mutating step shows the actual code. Every verification step lists the exact command + expected outcome. The shell binary is large (~350 LoC) but every helper / branch is fully written out — Task 31 is paste-and-build. + +**Type-consistency spot-checks:** +- `FileType.Console` (Plan 3.D landed the enum variant in `file.zig`; 3.E uses it consistently in Tasks 2, 7, 8, 9, 10). +- `console.feedByte`, `console.read`, `console.write`, `console.setMode`, `console.setFgPid`, `console.init` — all named consistently across Tasks 2, 3, 4, 7, 8, 9, 10. +- `inode.iupdate`, `inode.writei`, `inode.ialloc`, `inode.itrunc` — same name in declaration (Tasks 11, 12, 13, 14) and in callers (Tasks 14, 16, 17). +- `fsops.create`, `fsops.unlink` — same name in module (Task 16) and callers (Tasks 17, 18, 19). +- `O_CREAT`, `O_TRUNC`, `O_APPEND`, `STAT_FILE`, `STAT_DIR` — defined consistently in kernel `syscall.zig` (Task 17) and userland `ulib.zig` (Task 22). +- `addUserBinary` — same signature in helper (Task 24) and every call site (Tasks 25, 26, 27, 28, 29, 30, 31). +- `kernel_init_shell_step`, `kernel_sh_step`, etc. — referenced in `shell-fs-img` dependency wiring (Task 33) match the names declared in build target tasks (Tasks 25–31). + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-04-26-phase3-plan-e-fs-write-shell-utilities.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** From 28f4560a14b9b2560ec668e990e9685663df134a Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 20:43:41 +0800 Subject: [PATCH 02/47] feat(plic): add IRQ_UART_RX = 10 constant for Phase 3.E --- src/kernel/plic.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kernel/plic.zig b/src/kernel/plic.zig index 3862b3e..106b5b8 100644 --- a/src/kernel/plic.zig +++ b/src/kernel/plic.zig @@ -20,7 +20,7 @@ pub const PLIC_THRESHOLD_S: u32 = 0x0c20_1000; // S-context threshold pub const PLIC_CLAIM_S: u32 = 0x0c20_1004; // read = claim, write = complete pub const IRQ_BLOCK: u32 = 1; -// IRQ_UART_RX = 10 (3.E) +pub const IRQ_UART_RX: u32 = 10; pub fn setPriority(src: u32, prio: u32) void { const reg: *volatile u32 = @ptrFromInt(PLIC_PRIORITY_BASE + src * 4); From 9360cb29b519cea8399dd2358407df393b5f5b4f Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 20:47:04 +0800 Subject: [PATCH 03/47] feat(console): add console.zig skeleton (state + init + write + mode/fg_pid) --- src/kernel/console.zig | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/kernel/console.zig diff --git a/src/kernel/console.zig b/src/kernel/console.zig new file mode 100644 index 0000000..f741ded --- /dev/null +++ b/src/kernel/console.zig @@ -0,0 +1,86 @@ +// src/kernel/console.zig — Phase 3.E console line discipline. +// +// Backing for fd 0/1/2 in every process. Holds a 128-byte circular input +// buffer (xv6-style: `r`, `w`, `e` indices), a `mode` (Cooked vs Raw), +// and an `fg_pid` (the foreground process that ^C kills). +// +// API: +// init(): zero indices; mode = Cooked; fg_pid = 0. +// setMode(mode): 0 = Cooked, anything else = Raw. +// setFgPid(pid): who ^C kills. +// write(src_va, n): SUM-1 copy bytes through uart.writeByte. Returns n. +// feedByte(b): line discipline (Task 3). +// read(dst_va, n): sleep until r != w, copy bytes (Task 4). +// +// Single-hart: all state is global and uninstanced. + +const uart = @import("uart.zig"); + +pub const ConsoleMode = enum(u32) { Cooked = 0, Raw = 1 }; +pub const INPUT_BUF_SIZE: u32 = 128; + +pub var input: struct { + buf: [INPUT_BUF_SIZE]u8 = undefined, + r: u32 = 0, + w: u32 = 0, + e: u32 = 0, +} = .{}; + +pub var mode: ConsoleMode = .Cooked; +pub var fg_pid: u32 = 0; + +pub fn init() void { + input.r = 0; + input.w = 0; + input.e = 0; + mode = .Cooked; + fg_pid = 0; +} + +pub fn setMode(new_mode: u32) void { + mode = if (new_mode == 0) .Cooked else .Raw; +} + +pub fn setFgPid(pid: u32) void { + fg_pid = pid; +} + +const SSTATUS_SUM: u32 = 1 << 18; + +inline fn setSum() void { + asm volatile ("csrs sstatus, %[b]" + : + : [b] "r" (SSTATUS_SUM), + : .{ .memory = true }); +} + +inline fn clearSum() void { + asm volatile ("csrc sstatus, %[b]" + : + : [b] "r" (SSTATUS_SUM), + : .{ .memory = true }); +} + +/// SUM-1 copy `n` bytes from user VA `src_va` to the UART. Returns `n`. +pub fn write(src_va: u32, n: u32) i32 { + setSum(); + defer clearSum(); + var i: u32 = 0; + while (i < n) : (i += 1) { + const p: *const volatile u8 = @ptrFromInt(src_va + i); + uart.writeByte(p.*); + } + return @intCast(n); +} + +// Stubs for Tasks 3 + 4. Return safe defaults so the module compiles +// with no callers exercising them. +pub fn feedByte(b: u8) void { + _ = b; +} + +pub fn read(dst_va: u32, n: u32) i32 { + _ = dst_va; + _ = n; + return 0; +} From 9fdab0b344c536bbc681ddd18522417f2e582ea9 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 20:50:40 +0800 Subject: [PATCH 04/47] feat(console): implement cooked + raw line discipline in feedByte --- src/kernel/console.zig | 70 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/src/kernel/console.zig b/src/kernel/console.zig index f741ded..b667ce8 100644 --- a/src/kernel/console.zig +++ b/src/kernel/console.zig @@ -15,6 +15,7 @@ // Single-hart: all state is global and uninstanced. const uart = @import("uart.zig"); +const proc = @import("proc.zig"); pub const ConsoleMode = enum(u32) { Cooked = 0, Raw = 1 }; pub const INPUT_BUF_SIZE: u32 = 128; @@ -73,12 +74,75 @@ pub fn write(src_va: u32, n: u32) i32 { return @intCast(n); } -// Stubs for Tasks 3 + 4. Return safe defaults so the module compiles -// with no callers exercising them. pub fn feedByte(b: u8) void { - _ = b; + if (mode == .Raw) { + // Raw: append, wake, no echo, no special handling. + if (input.e -% input.r >= INPUT_BUF_SIZE) return; // buf full — drop + input.buf[input.e % INPUT_BUF_SIZE] = b; + input.e += 1; + input.w = input.e; + proc.wakeup(@intFromPtr(&input.r)); + return; + } + + // Cooked. + switch (b) { + 0x03 => { // ^C + // Erase any in-progress line (between w and e). + while (input.e != input.w) : (input.e -%= 1) { + uart.writeByte(0x08); + uart.writeByte(' '); + uart.writeByte(0x08); + } + uart.writeByte('^'); + uart.writeByte('C'); + uart.writeByte('\n'); + // Discard any committed-but-not-yet-read bytes too — clean slate. + input.r = input.w; + // Kill foreground. + if (fg_pid != 0) _ = proc.kill(fg_pid); + proc.wakeup(@intFromPtr(&input.r)); + }, + 0x15 => { // ^U — kill current line + while (input.e != input.w) : (input.e -%= 1) { + uart.writeByte(0x08); + uart.writeByte(' '); + uart.writeByte(0x08); + } + }, + 0x08, 0x7F => { // backspace / DEL + if (input.e != input.w) { + input.e -%= 1; + uart.writeByte(0x08); + uart.writeByte(' '); + uart.writeByte(0x08); + } + }, + 0x04 => { // ^D EOF + // Commit whatever's typed; reader will see r == w after consuming. + input.w = input.e; + proc.wakeup(@intFromPtr(&input.r)); + }, + else => { + const c: u8 = if (b == '\r') '\n' else b; + // Drop unprintable control bytes other than \n. + if (c != '\n' and (c < 0x20 or c == 0x7F)) return; + // Drop if buf is full. + if (input.e -% input.r >= INPUT_BUF_SIZE) return; + input.buf[input.e % INPUT_BUF_SIZE] = c; + input.e += 1; + uart.writeByte(c); + if (c == '\n') { + input.w = input.e; + proc.wakeup(@intFromPtr(&input.r)); + } + }, + } } +// Stub for Task 4. Return safe defaults so the module compiles +// with no callers exercising them. + pub fn read(dst_va: u32, n: u32) i32 { _ = dst_va; _ = n; From bb6edd4308ca872ae20111737502238f3fc6e0d6 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 20:54:29 +0800 Subject: [PATCH 05/47] feat(console): implement read() with sleep-on-input + SUM-1 copy --- src/kernel/console.zig | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/kernel/console.zig b/src/kernel/console.zig index b667ce8..f77a9a4 100644 --- a/src/kernel/console.zig +++ b/src/kernel/console.zig @@ -140,11 +140,33 @@ pub fn feedByte(b: u8) void { } } -// Stub for Task 4. Return safe defaults so the module compiles -// with no callers exercising them. - +/// SUM-1 copy `n` bytes from input buffer to user VA `dst_va`. +/// Sleep on input.r until a byte is available. Handle ^D EOF marker. +/// Return bytes delivered (0 if immediate EOF). pub fn read(dst_va: u32, n: u32) i32 { - _ = dst_va; - _ = n; - return 0; + var got: u32 = 0; + while (got < n) { + // Wait for at least one byte to be deliverable. + while (input.r == input.w) { + if (proc.cur().killed != 0) return -1; + proc.sleep(@intFromPtr(&input.r)); + } + const c = input.buf[input.r % INPUT_BUF_SIZE]; + input.r += 1; + + // ^D in the buffer: an EOF marker. Consume but don't deliver. + if (c == 0x04) { + // If we already delivered something, return it; else 0 = EOF. + break; + } + + setSum(); + const dst: *volatile u8 = @ptrFromInt(dst_va + got); + dst.* = c; + clearSum(); + got += 1; + + if (c == '\n') break; + } + return @intCast(got); } From 9be6800991e23fd522f5f06d40bef5d373261774 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 20:59:42 +0800 Subject: [PATCH 06/47] feat(uart): add RX MMIO + readByte + isr (drains FIFO into console) --- src/kernel/uart.zig | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/kernel/uart.zig b/src/kernel/uart.zig index 90cae9a..0e768ec 100644 --- a/src/kernel/uart.zig +++ b/src/kernel/uart.zig @@ -1,10 +1,18 @@ // src/kernel/uart.zig — kernel-side NS16550A UART helper. // -// We only ever transmit; receive is not implemented in Phase 2. The -// emulator's uart.zig forwards THR stores straight to stdout, so -// writeByte is effectively a putchar. +// Phase 3.E adds the RX path. The emulator's uart.zig forwards THR +// stores straight to stdout (writeByte), and now also fills an RX FIFO +// from --input or stdin. PLIC source 10 fires whenever the FIFO is +// non-empty (level-triggered). -pub const UART_THR: u32 = 0x10000000; +const console = @import("console.zig"); + +pub const UART_BASE: u32 = 0x1000_0000; +pub const UART_THR: u32 = UART_BASE + 0x0; // transmit hold (write) +pub const UART_RBR: u32 = UART_BASE + 0x0; // receive buffer (read) +pub const UART_LSR: u32 = UART_BASE + 0x5; // line status +pub const LSR_THRE: u8 = 1 << 5; // transmitter empty +pub const LSR_DR: u8 = 1 << 0; // data ready pub fn writeByte(b: u8) void { const thr: *volatile u8 = @ptrFromInt(UART_THR); @@ -14,3 +22,20 @@ pub fn writeByte(b: u8) void { pub fn writeBytes(s: []const u8) void { for (s) |b| writeByte(b); } + +/// Read one byte from the RX FIFO. Returns null if the FIFO is empty. +pub fn readByte() ?u8 { + const lsr: *const volatile u8 = @ptrFromInt(UART_LSR); + if ((lsr.* & LSR_DR) == 0) return null; + const rbr: *const volatile u8 = @ptrFromInt(UART_RBR); + return rbr.*; +} + +/// PLIC src 10 ISR. Drain the FIFO into the console line discipline. +/// MUST loop until the FIFO is empty — IRQ is level-triggered, so any +/// remaining bytes would re-enter us immediately. +pub fn isr() void { + while (readByte()) |b| { + console.feedByte(b); + } +} From 65d242a5b8d1649466baada02c673b3bcaeb1862 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:04:21 +0800 Subject: [PATCH 07/47] feat(trap): dispatch PLIC IRQ #10 (UART RX) to uart.isr --- src/kernel/trap.zig | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/kernel/trap.zig b/src/kernel/trap.zig index aa4ad44..8fe01d3 100644 --- a/src/kernel/trap.zig +++ b/src/kernel/trap.zig @@ -107,6 +107,7 @@ const syscall = @import("syscall.zig"); const proc = @import("proc.zig"); const plic = @import("plic.zig"); const block = @import("block.zig"); +const uart = @import("uart.zig"); fn readScause() u32 { return asm volatile ("csrr %[out], scause" @@ -122,11 +123,7 @@ fn readStval() u32 { fn clearSipSsip() void { // sip.SSIP is bit 1. `csrci sip, 2` clears it. - asm volatile ("csrci sip, 2" - : - : - : .{ .memory = true } - ); + asm volatile ("csrci sip, 2" ::: .{ .memory = true }); } /// S-from-S trap dispatcher. Installed via stvec only while sched.scheduler @@ -154,6 +151,7 @@ export fn s_kernel_trap_dispatch() callconv(.c) void { const irq = plic.claim(); switch (irq) { plic.IRQ_BLOCK => block.isr(), + plic.IRQ_UART_RX => uart.isr(), else => kprintf.panic("unhandled PLIC src in kernel trap: {d}", .{irq}), } plic.complete(irq); @@ -231,6 +229,7 @@ export fn s_trap_dispatch(tf: *TrapFrame) callconv(.c) void { const irq = plic.claim(); switch (irq) { plic.IRQ_BLOCK => block.isr(), + plic.IRQ_UART_RX => uart.isr(), else => kprintf.panic("unhandled PLIC src: {d}", .{irq}), } plic.complete(irq); From e1331e8d6c1e26ba14c70734c185355f02f73385 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:08:01 +0800 Subject: [PATCH 08/47] docs(trap): refresh stale 3.E comment now that IRQ #10 is wired --- src/kernel/trap.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kernel/trap.zig b/src/kernel/trap.zig index 8fe01d3..e8b24c5 100644 --- a/src/kernel/trap.zig +++ b/src/kernel/trap.zig @@ -222,7 +222,7 @@ export fn s_trap_dispatch(tf: *TrapFrame) callconv(.c) void { // to its ISR, then complete so the device can re-assert when the // next edge fires. // - // 3.D wires only IRQ #1 (block); 3.E will add IRQ #10 (UART RX). + // 3.D wires IRQ #1 (block); 3.E adds IRQ #10 (UART RX). // An unknown/0 source means a spurious interrupt — the spec // permits 0 here when claim races a clear; we panic to surface // any kernel bug that wires a source we can't service. From 81019730c0f1e3f0e8bbbf79b94a16b5187b63e0 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:10:03 +0800 Subject: [PATCH 09/47] feat(syscall): wire sysSetFgPid/sysConsoleSetMode + killed-flag check --- src/kernel/syscall.zig | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/kernel/syscall.zig b/src/kernel/syscall.zig index bb563da..7dad654 100644 --- a/src/kernel/syscall.zig +++ b/src/kernel/syscall.zig @@ -18,6 +18,7 @@ const proc = @import("proc.zig"); const page_alloc = @import("page_alloc.zig"); const vm = @import("vm.zig"); const file = @import("file.zig"); +const console = @import("console.zig"); const inode = @import("fs/inode.zig"); const path_mod = @import("fs/path.zig"); const layout = @import("fs/layout.zig"); @@ -285,7 +286,7 @@ fn sysGetcwd(buf_user_va: u32, sz: u32) i32 { /// `^C` should target. 3.C accepts and discards; 3.E (when the console /// line discipline lands) wires this to the actual fg_pid global. fn sysSetFgPid(pid: u32) u32 { - _ = pid; + console.setFgPid(pid); return 0; } @@ -293,7 +294,7 @@ fn sysSetFgPid(pid: u32) u32 { /// line discipline. 3.C accepts and discards; 3.E wires this to the /// console state machine. fn sysConsoleSetMode(mode: u32) u32 { - _ = mode; + console.setMode(mode); return 0; } @@ -318,4 +319,10 @@ pub fn dispatch(tf: *trap.TrapFrame) void { 5001 => tf.a0 = sysConsoleSetMode(tf.a0), else => tf.a0 = @bitCast(@as(i32, -38)), // -ENOSYS } + + // Phase 3.E: if the process was killed (e.g. by ^C while sleeping + // in this syscall), exit on the way back to user instead of returning. + if (proc.cur().killed != 0) { + proc.exit(-1); + } } From ec89a2e3d70e8d98fd0e248ea46f599b2802d68a Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:17:57 +0800 Subject: [PATCH 10/47] feat(file): add Console handling to read/lseek/fstat + new write (Inode stub) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kernel/file.zig | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/kernel/file.zig b/src/kernel/file.zig index 26b0b0f..4efd603 100644 --- a/src/kernel/file.zig +++ b/src/kernel/file.zig @@ -19,6 +19,7 @@ const std = @import("std"); const inode = @import("fs/inode.zig"); const proc = @import("proc.zig"); const layout = @import("fs/layout.zig"); +const console = @import("console.zig"); pub const NFILE: u32 = 64; const READ_CHUNK: u32 = 4096; @@ -106,6 +107,11 @@ var read_kbuf: [READ_CHUNK]u8 align(4) = undefined; pub fn read(idx: u32, dst_user_va: u32, n: u32) i32 { if (idx == 0 or idx >= NFILE) return -1; const f = &ftable[idx]; + + if (f.type == .Console) { + return console.read(dst_user_va, n); + } + if (f.type != .Inode or f.ip == null) return -1; const want = if (n > READ_CHUNK) READ_CHUNK else n; @@ -127,9 +133,30 @@ pub fn read(idx: u32, dst_user_va: u32, n: u32) i32 { return @intCast(got); } +// Static staging buffer for file.write inode path (Task 12 fills in writei). +var write_kbuf: [4096]u8 align(4) = undefined; + +/// Write up to `n` bytes from user VA `src_user_va` to file `idx`. +/// Returns bytes written (≥ 0) or -1 on bad fd. +pub fn write(idx: u32, src_user_va: u32, n: u32) i32 { + if (idx == 0 or idx >= NFILE) return -1; + const f = &ftable[idx]; + + if (f.type == .Console) { + return console.write(src_user_va, n); + } + + if (f.type != .Inode or f.ip == null) return -1; + + // STUB until Task 12 lands inode.writei. Discards bytes silently. + _ = &write_kbuf; + return -1; +} + pub fn lseek(idx: u32, off: i32, whence: u32) i32 { if (idx == 0 or idx >= NFILE) return -1; const f = &ftable[idx]; + if (f.type == .Console) return -1; // not seekable if (f.type != .Inode or f.ip == null) return -1; const new_off: i64 = switch (whence) { @@ -152,6 +179,14 @@ pub fn lseek(idx: u32, off: i32, whence: u32) i32 { pub fn fstat(idx: u32, stat_user_va: u32) i32 { if (idx == 0 or idx >= NFILE) return -1; const f = &ftable[idx]; + if (f.type == .Console) { + const stat: Stat = .{ .type = @intFromEnum(layout.FileType.File), .size = 0 }; + setSum(); + const dst: *volatile Stat = @ptrFromInt(stat_user_va); + dst.* = stat; + clearSum(); + return 0; + } if (f.type != .Inode or f.ip == null) return -1; inode.ilock(f.ip.?); From 5382e79509d3da9f460aae14215f41f624d9969e Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:23:52 +0800 Subject: [PATCH 11/47] feat(kmain): install console as fds 0/1/2 on PID 1 in FS_DEMO arm Enable IRQ_UART_RX in PLIC, call console.init() + file.init(), allocate one Console File entry with ref_count=3, and install it as ofile[0..2] on init_p so /bin/init inherits stdin/stdout/stderr. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kernel/kmain.zig | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/kernel/kmain.zig b/src/kernel/kmain.zig index b6755e2..1decfe7 100644 --- a/src/kernel/kmain.zig +++ b/src/kernel/kmain.zig @@ -18,6 +18,8 @@ const plic = @import("plic.zig"); const block = @import("block.zig"); const bufcache = @import("fs/bufcache.zig"); const inode = @import("fs/inode.zig"); +const file = @import("file.zig"); +const console = @import("console.zig"); const SATP_MODE_SV32: u32 = 1 << 31; @@ -41,6 +43,10 @@ export fn kmain() callconv(.c) noreturn { plic.setPriority(plic.IRQ_BLOCK, 1); plic.enable(plic.IRQ_BLOCK); plic.setThreshold(0); + plic.setPriority(plic.IRQ_UART_RX, 1); + plic.enable(plic.IRQ_UART_RX); + // (threshold already set to 0 above; same threshold gates both sources) + console.init(); const init_p = proc.alloc() orelse kprintf.panic("kmain: alloc init", .{}); @memcpy(init_p.name[0..4], "init"); @@ -51,6 +57,21 @@ export fn kmain() callconv(.c) noreturn { init_p.sz = 0; init_p.cwd = 0; // lazy-root + // Phase 3.E: initialize file table + install console fds 0/1/2 onto + // init so /bin/init inherits stdin/stdout/stderr. + file.init(); + + const console_fidx = file.alloc() orelse kprintf.panic("kmain: file.alloc console", .{}); + file.ftable[console_fidx].type = .Console; + file.ftable[console_fidx].ip = null; + file.ftable[console_fidx].off = 0; + // alloc gave us ref_count=1; bring to 3 (one per fd 0/1/2). + _ = file.dup(console_fidx); + _ = file.dup(console_fidx); + init_p.ofile[0] = console_fidx; + init_p.ofile[1] = console_fidx; + init_p.ofile[2] = console_fidx; + // Install S-mode trap setup BEFORE exec — exec calls block.read // which sleeps, which transitively requires the IRQ + trap path // to be ready. From 0f8495155b588a8a501eeed09664c6f945c4665d Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:31:56 +0800 Subject: [PATCH 12/47] refactor(syscall): route sysWrite through file.write (file table dispatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace sysWrite's hard-coded UART path with file.write dispatch: - sysWrite now routes through file.ofile[fd] → file.write → console.write - sysRead already used this pattern; now both are symmetric - Return type changed from u32 to i32 for proper error propagation - Dispatch arm 64 updated with @bitCast to handle signed return - Dropped unused uart import from syscall.zig Also install console fds 0/1/2 in FORK_DEMO arm so init inherits stdin/stdout/stderr (was only in FS_DEMO after Task 9). This ensures sysWrite through file.write works consistently in both boot modes. All e2e tests pass: e2e-fs and e2e-fork maintain original behavior (write to UART via file.write → console.write). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kernel/kmain.zig | 15 +++++++++++++++ src/kernel/syscall.zig | 20 ++++++-------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/kernel/kmain.zig b/src/kernel/kmain.zig index 1decfe7..71a2c10 100644 --- a/src/kernel/kmain.zig +++ b/src/kernel/kmain.zig @@ -163,6 +163,21 @@ export fn kmain() callconv(.c) noreturn { init_p.sz = vm.USER_TEXT_VA + 0x10000; init_p.state = .Runnable; + // Phase 3.E: initialize file table + install console fds 0/1/2 onto + // init so /bin/init inherits stdin/stdout/stderr. + file.init(); + + const console_fidx = file.alloc() orelse kprintf.panic("kmain: file.alloc console", .{}); + file.ftable[console_fidx].type = .Console; + file.ftable[console_fidx].ip = null; + file.ftable[console_fidx].off = 0; + // alloc gave us ref_count=1; bring to 3 (one per fd 0/1/2). + _ = file.dup(console_fidx); + _ = file.dup(console_fidx); + init_p.ofile[0] = console_fidx; + init_p.ofile[1] = console_fidx; + init_p.ofile[2] = console_fidx; + // Skip the single + multi setup blocks below — install stvec + sscratch // + sstatus and jump into scheduler() the same way they do. const stvec_val_fork: u32 = @intCast(@intFromPtr(&s_trap_entry)); diff --git a/src/kernel/syscall.zig b/src/kernel/syscall.zig index 7dad654..5af0106 100644 --- a/src/kernel/syscall.zig +++ b/src/kernel/syscall.zig @@ -13,7 +13,6 @@ // ABI unchanged: a7 = syscall number, a0..a5 = args, a0 = return. const trap = @import("trap.zig"); -const uart = @import("uart.zig"); const proc = @import("proc.zig"); const page_alloc = @import("page_alloc.zig"); const vm = @import("vm.zig"); @@ -55,18 +54,11 @@ fn copyStrFromUser(user_va: u32, buf: []u8) ?[]u8 { return null; } -fn sysWrite(fd: u32, buf_va: u32, len: u32) u32 { - if (fd != 1 and fd != 2) { - return @bitCast(@as(i32, -9)); // -EBADF - } - setSum(); - var i: u32 = 0; - while (i < len) : (i += 1) { - const p: *const volatile u8 = @ptrFromInt(buf_va + i); - uart.writeByte(p.*); - } - clearSum(); - return len; +fn sysWrite(fd: u32, buf_va: u32, len: u32) i32 { + if (fd >= proc.NOFILE) return -1; + const idx = proc.cur().ofile[fd]; + if (idx == 0) return -1; + return file.write(idx, buf_va, len); } pub fn sysExit(status: u32) noreturn { @@ -306,7 +298,7 @@ pub fn dispatch(tf: *trap.TrapFrame) void { 57 => tf.a0 = @bitCast(sysClose(tf.a0)), 62 => tf.a0 = @bitCast(sysLseek(tf.a0, tf.a1, tf.a2)), 63 => tf.a0 = @bitCast(sysRead(tf.a0, tf.a1, tf.a2)), - 64 => tf.a0 = sysWrite(tf.a0, tf.a1, tf.a2), + 64 => tf.a0 = @bitCast(sysWrite(tf.a0, tf.a1, tf.a2)), 80 => tf.a0 = @bitCast(sysFstat(tf.a0, tf.a1)), 93 => sysExit(tf.a0), 124 => tf.a0 = sysYield(), From 1d68b08724f3a973b597ad400a34bd0d9720c941 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:38:03 +0800 Subject: [PATCH 13/47] =?UTF-8?q?feat(inode):=20add=20iupdate()=20?= =?UTF-8?q?=E2=80=94=20flush=20dinode=20back=20to=20inode-table=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kernel/fs/inode.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/kernel/fs/inode.zig b/src/kernel/fs/inode.zig index 2202667..f7168de 100644 --- a/src/kernel/fs/inode.zig +++ b/src/kernel/fs/inode.zig @@ -101,6 +101,17 @@ pub fn iput(ip: *InMemInode) void { // the dinode + bwrite the inode block. 3.D never reaches this branch. } +/// Flush this inode's in-memory dinode back to its slot in the inode table. +pub fn iupdate(ip: *InMemInode) void { + const blk = layout.INODE_START_BLK + ip.inum / layout.INODES_PER_BLOCK; + const slot = ip.inum % layout.INODES_PER_BLOCK; + const buf = bufcache.bread(blk); + const inodes: [*]layout.DiskInode = @ptrCast(@alignCast(&buf.data[0])); + inodes[slot] = ip.dinode; + bufcache.bwrite(buf); + bufcache.brelse(buf); +} + /// Map logical block index `bn` (0-based) within the file to its on-disk /// block number. Returns 0 for blocks past the file's allocated extent /// (caller's readi treats 0 as a hole / EOF). From 9e5c030f231b86ce9ad084daa61d1d2281fbe840 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:42:57 +0800 Subject: [PATCH 14/47] feat(inode): add writei + bmap.for_write flag (lazy block alloc) Co-Authored-By: Claude Sonnet 4.6 --- src/kernel/file.zig | 20 ++++++-- src/kernel/fs/inode.zig | 103 ++++++++++++++++++++++++++++++++++------ 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/src/kernel/file.zig b/src/kernel/file.zig index 4efd603..60134d2 100644 --- a/src/kernel/file.zig +++ b/src/kernel/file.zig @@ -148,9 +148,23 @@ pub fn write(idx: u32, src_user_va: u32, n: u32) i32 { if (f.type != .Inode or f.ip == null) return -1; - // STUB until Task 12 lands inode.writei. Discards bytes silently. - _ = &write_kbuf; - return -1; + const want = if (n > write_kbuf.len) write_kbuf.len else n; + + // SUM-1 copy from user into kernel staging buffer. + setSum(); + var i: u32 = 0; + while (i < want) : (i += 1) { + const src_p: *const volatile u8 = @ptrFromInt(src_user_va + i); + write_kbuf[i] = src_p.*; + } + clearSum(); + + inode.ilock(f.ip.?); + const wrote = inode.writei(f.ip.?, &write_kbuf, f.off, @intCast(want)); + inode.iunlock(f.ip.?); + + if (wrote > 0) f.off += @intCast(wrote); + return wrote; } pub fn lseek(idx: u32, off: i32, whence: u32) i32 { diff --git a/src/kernel/fs/inode.zig b/src/kernel/fs/inode.zig index f7168de..b75ba4d 100644 --- a/src/kernel/fs/inode.zig +++ b/src/kernel/fs/inode.zig @@ -15,14 +15,19 @@ // iunlock(ip): busy=false; wakeup waiters. // iput(ip): refs -= 1. (3.E adds the on-zero on-disk // truncate when nlink == 0.) -// bmap(ip, bn): translate logical block bn to disk block#. -// Returns 0 for unallocated entries (read-as-EOF). +// bmap(ip, bn, for_write): translate logical block bn to disk block#. +// When for_write=true, lazily allocates missing +// blocks. Returns 0 for holes (for_write=false) +// or on out-of-disk (for_write=true). // readi(ip, dst, off, n): copy n bytes into dst. Returns bytes copied // (clamped to ip.dinode.size). +// writei(ip, src, off, n): write n bytes from src at offset off, growing +// the file as needed. Returns bytes written. const std = @import("std"); const layout = @import("layout.zig"); const bufcache = @import("bufcache.zig"); +const balloc = @import("balloc.zig"); const proc = @import("../proc.zig"); const kprintf = @import("../kprintf.zig"); @@ -113,23 +118,56 @@ pub fn iupdate(ip: *InMemInode) void { } /// Map logical block index `bn` (0-based) within the file to its on-disk -/// block number. Returns 0 for blocks past the file's allocated extent -/// (caller's readi treats 0 as a hole / EOF). +/// block number. When `for_write` is true and the entry is unallocated (== 0), +/// allocates a fresh block via balloc.alloc (zero-filling the indirect block +/// on first allocation). Returns 0 for holes when for_write=false, or on +/// out-of-disk when for_write=true. /// /// Caller MUST hold ip locked (busy=true, valid=true). -pub fn bmap(ip: *InMemInode, bn: u32) u32 { +pub fn bmap(ip: *InMemInode, bn: u32, for_write: bool) u32 { if (bn < layout.NDIRECT) { - return ip.dinode.addrs[bn]; + var addr = ip.dinode.addrs[bn]; + if (addr == 0 and for_write) { + addr = balloc.alloc(); + if (addr == 0) return 0; + ip.dinode.addrs[bn] = addr; + // Caller is responsible for iupdate after the write. + } + return addr; } - if (bn < layout.NDIRECT + layout.NINDIRECT) { - const ind_blk = ip.dinode.addrs[layout.NDIRECT]; - if (ind_blk == 0) return 0; - const b = bufcache.bread(ind_blk); - defer bufcache.brelse(b); - const ptrs: [*]const u32 = @ptrCast(@alignCast(&b.data[0])); - return ptrs[bn - layout.NDIRECT]; + + const ix = bn - layout.NDIRECT; + if (ix >= layout.NINDIRECT) { + kprintf.panic("bmap: out of range bn={d}", .{bn}); + } + + var ind = ip.dinode.addrs[layout.NDIRECT]; + if (ind == 0) { + if (!for_write) return 0; + ind = balloc.alloc(); + if (ind == 0) return 0; + ip.dinode.addrs[layout.NDIRECT] = ind; + // Zero-fill the new indirect block so unused entries read back as 0. + const zbuf = bufcache.bread(ind); + @memset(&zbuf.data, 0); + bufcache.bwrite(zbuf); + bufcache.brelse(zbuf); } - kprintf.panic("bmap: bn {d} > MAX_FILE_BLOCKS", .{bn}); + + const buf = bufcache.bread(ind); + const slots: [*]u32 = @ptrCast(@alignCast(&buf.data[0])); + var addr = slots[ix]; + if (addr == 0 and for_write) { + addr = balloc.alloc(); + if (addr == 0) { + bufcache.brelse(buf); + return 0; + } + slots[ix] = addr; + bufcache.bwrite(buf); + } + bufcache.brelse(buf); + return addr; } /// Copy up to n bytes from inode at offset off into dst. Reads past @@ -151,7 +189,7 @@ pub fn readi(ip: *InMemInode, dst: [*]u8, off: u32, n: u32) u32 { else remain; - const dblk = bmap(ip, bn); + const dblk = bmap(ip, bn, false); if (dblk == 0) { // Hole — readi returns zeros without touching disk. var i: u32 = 0; @@ -166,3 +204,38 @@ pub fn readi(ip: *InMemInode, dst: [*]u8, off: u32, n: u32) u32 { } return copied; } + +/// Write `n` bytes from `src` to inode `ip` starting at offset `off`. +/// Returns bytes actually written (may be < n if disk fills) or -1 on +/// bad arguments. +pub fn writei(ip: *InMemInode, src: [*]const u8, off: u32, n: u32) i32 { + if (off + n > layout.MAX_FILE_BLOCKS * layout.BLOCK_SIZE) return -1; + + var written: u32 = 0; + while (written < n) { + const cur_off = off + written; + const bn = cur_off / layout.BLOCK_SIZE; + const within = cur_off % layout.BLOCK_SIZE; + const remain_block = layout.BLOCK_SIZE - within; + const remain_total = n - written; + const chunk = if (remain_block < remain_total) remain_block else remain_total; + + const blk = bmap(ip, bn, true); + if (blk == 0) break; // out of disk + + const buf = bufcache.bread(blk); + var i: u32 = 0; + while (i < chunk) : (i += 1) { + buf.data[within + i] = src[written + i]; + } + bufcache.bwrite(buf); + bufcache.brelse(buf); + written += chunk; + } + + if (off + written > ip.dinode.size) { + ip.dinode.size = off + written; + } + iupdate(ip); + return @intCast(written); +} From 909ac06778538a094b59ca51225edb91d89ed314 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:48:05 +0800 Subject: [PATCH 15/47] =?UTF-8?q?feat(inode):=20add=20ialloc()=20=E2=80=94?= =?UTF-8?q?=20claim=20first=20free=20inode=20+=20return=20cached=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kernel/fs/inode.zig | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/kernel/fs/inode.zig b/src/kernel/fs/inode.zig index b75ba4d..f87854c 100644 --- a/src/kernel/fs/inode.zig +++ b/src/kernel/fs/inode.zig @@ -117,6 +117,39 @@ pub fn iupdate(ip: *InMemInode) void { bufcache.brelse(buf); } +/// Find the first free disk inode (type == .Free), claim it with the +/// given type (writes back via bwrite), and return the in-memory cache +/// entry holding it. Returns null on full inode table. +pub fn ialloc(itype: layout.FileType) ?*InMemInode { + var inum: u32 = 1; // inum 0 is the "no inode" sentinel; root is inum 1 + while (inum < layout.NINODES) : (inum += 1) { + const blk = layout.INODE_START_BLK + inum / layout.INODES_PER_BLOCK; + const slot = inum % layout.INODES_PER_BLOCK; + const buf = bufcache.bread(blk); + const inodes: [*]layout.DiskInode = @ptrCast(@alignCast(&buf.data[0])); + if (inodes[slot].type == .Free) { + inodes[slot] = .{ + .type = itype, + .nlink = 1, + .size = 0, + .addrs = std.mem.zeroes([layout.NDIRECT + 1]u32), + ._reserved = std.mem.zeroes([4]u8), + }; + bufcache.bwrite(buf); + bufcache.brelse(buf); + + // Bring it into the in-memory cache. + const ip = iget(inum); + ilock(ip); + // ip.dinode now reflects what we just wrote. + iunlock(ip); + return ip; + } + bufcache.brelse(buf); + } + return null; +} + /// Map logical block index `bn` (0-based) within the file to its on-disk /// block number. When `for_write` is true and the entry is unallocated (== 0), /// allocates a fresh block via balloc.alloc (zero-filling the indirect block From 2c8dd0f11c8f81a5a85e02e646a1b6eb68dbcebb Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:53:02 +0800 Subject: [PATCH 16/47] feat(inode): add itrunc + iput-on-zero truncate (free blocks when nlink==0) Co-Authored-By: Claude Sonnet 4.6 --- src/kernel/fs/inode.zig | 43 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/kernel/fs/inode.zig b/src/kernel/fs/inode.zig index f87854c..55b8597 100644 --- a/src/kernel/fs/inode.zig +++ b/src/kernel/fs/inode.zig @@ -100,10 +100,20 @@ pub fn iunlock(ip: *InMemInode) void { } pub fn iput(ip: *InMemInode) void { - if (ip.refs == 0) kprintf.panic("iput: refs == 0 (inum {d})", .{ip.inum}); - ip.refs -= 1; - // 3.E: if refs == 0 and dinode.nlink == 0, ilock + truncate + zero - // the dinode + bwrite the inode block. 3.D never reaches this branch. + // If this is the last in-memory ref AND the file has been unlinked + // (nlink == 0), it's our job to free its on-disk resources. + if (ip.refs == 1 and ip.valid and ip.dinode.nlink == 0) { + // ilock-equivalent: ip is single-refed and we're the only caller, + // so busy is necessarily false. Set busy to keep the invariant + // (in case any future caller checks). + ip.busy = true; + itrunc(ip); + ip.dinode.type = .Free; + iupdate(ip); + ip.valid = false; + ip.busy = false; + } + if (ip.refs > 0) ip.refs -= 1; } /// Flush this inode's in-memory dinode back to its slot in the inode table. @@ -150,6 +160,31 @@ pub fn ialloc(itype: layout.FileType) ?*InMemInode { return null; } +/// Free every block held by `ip` (direct + indirect) and reset size to 0. +/// Caller must hold ip.busy (i.e., must have ilock'd ip). +pub fn itrunc(ip: *InMemInode) void { + var i: u32 = 0; + while (i < layout.NDIRECT) : (i += 1) { + if (ip.dinode.addrs[i] != 0) { + balloc.free(ip.dinode.addrs[i]); + ip.dinode.addrs[i] = 0; + } + } + if (ip.dinode.addrs[layout.NDIRECT] != 0) { + const buf = bufcache.bread(ip.dinode.addrs[layout.NDIRECT]); + const slots: [*]const u32 = @ptrCast(@alignCast(&buf.data[0])); + var j: u32 = 0; + while (j < layout.NINDIRECT) : (j += 1) { + if (slots[j] != 0) balloc.free(slots[j]); + } + bufcache.brelse(buf); + balloc.free(ip.dinode.addrs[layout.NDIRECT]); + ip.dinode.addrs[layout.NDIRECT] = 0; + } + ip.dinode.size = 0; + iupdate(ip); +} + /// Map logical block index `bn` (0-based) within the file to its on-disk /// block number. When `for_write` is true and the entry is unallocated (== 0), /// allocates a fresh block via balloc.alloc (zero-filling the indirect block From ee28fbe9bf16d9dfc3dc5d1535e54b816a903e61 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 21:59:03 +0800 Subject: [PATCH 17/47] feat(dir): real dirlink (append/find-free) + dirunlink (zero slot) Co-Authored-By: Claude Sonnet 4.6 --- src/kernel/fs/dir.zig | 81 ++++++++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 24 deletions(-) diff --git a/src/kernel/fs/dir.zig b/src/kernel/fs/dir.zig index 9c0a296..d9e198c 100644 --- a/src/kernel/fs/dir.zig +++ b/src/kernel/fs/dir.zig @@ -1,4 +1,4 @@ -// src/kernel/fs/dir.zig — Phase 3.D directory operations. +// src/kernel/fs/dir.zig — Phase 3.E directory operations. // // Directories store an array of 16-byte DirEntry records back-to-back // inside their inode's data blocks. inum == 0 means a free slot. @@ -6,15 +6,23 @@ // API: // dirlookup(dir, name): linear-scan dir's data via readi; return // the matching entry's inum or null. -// dirlink(dir, name, inum): NOT implemented in 3.D (write path is -// 3.E). Returns false unconditionally so -// 3.D's syscalls compile against a stable -// API surface. +// dirlink(dir, name, inum): find free slot or append; write DirEntry. +// dirunlink(dir, name): zero the matching slot. const std = @import("std"); const layout = @import("layout.zig"); const inode = @import("inode.zig"); +/// Compare a directory entry's NUL-padded name slot against an exact-length target. +fn nameEq(slot_name: []const u8, target: []const u8) bool { + if (target.len >= slot_name.len) return false; + var i: u32 = 0; + while (i < target.len) : (i += 1) { + if (slot_name[i] != target[i]) return false; + } + return slot_name[target.len] == 0; +} + pub fn dirlookup(dir: *inode.InMemInode, name: []const u8) ?u32 { if (dir.dinode.type != .Dir) return null; if (name.len == 0 or name.len > layout.DIR_NAME_LEN - 1) return null; @@ -26,29 +34,54 @@ pub fn dirlookup(dir: *inode.InMemInode, name: []const u8) ?u32 { if (got != @sizeOf(layout.DirEntry)) return null; if (de.inum == 0) continue; - // Compare name (NUL-terminated within the 14-byte field). - var i: u32 = 0; - var match = true; - while (i < name.len) : (i += 1) { - if (de.name[i] != name[i]) { - match = false; - break; - } - } - // The byte after the name (if any) must be NUL or the end. - if (match and (name.len == layout.DIR_NAME_LEN or de.name[name.len] == 0)) { - return de.inum; - } + if (nameEq(de.name[0..], name)) return de.inum; } return null; } -/// 3.D stub. 3.E implements the real write-path body that finds an -/// inum==0 slot (or appends), constructs a DirEntry, and writes it -/// via writei. pub fn dirlink(dir: *inode.InMemInode, name: []const u8, inum: u16) bool { - _ = dir; - _ = name; - _ = inum; + if (name.len == 0 or name.len >= layout.DIR_NAME_LEN) return false; + + // Reject duplicates. + var off: u32 = 0; + var de: layout.DirEntry = undefined; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) return false; + if (de.inum != 0 and nameEq(de.name[0..], name)) return false; + } + + // Find first free slot OR fall through to append at end. + off = 0; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) break; + if (de.inum == 0) break; + } + // Note: if the loop ran to completion without finding a free slot, + // `off == dir.dinode.size` — writei will extend the directory. + + var entry: layout.DirEntry = .{ .inum = inum, .name = std.mem.zeroes([layout.DIR_NAME_LEN]u8) }; + var i: u32 = 0; + while (i < name.len) : (i += 1) entry.name[i] = name[i]; + // Remaining bytes already zero from std.mem.zeroes. + + const wrote = inode.writei(dir, @ptrCast(&entry), off, @sizeOf(layout.DirEntry)); + return wrote == @sizeOf(layout.DirEntry); +} + +pub fn dirunlink(dir: *inode.InMemInode, name: []const u8) bool { + var off: u32 = 0; + var de: layout.DirEntry = undefined; + while (off < dir.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) return false; + if (de.inum != 0 and nameEq(de.name[0..], name)) { + de.inum = 0; + de.name = std.mem.zeroes([layout.DIR_NAME_LEN]u8); + const wrote = inode.writei(dir, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + return wrote == @sizeOf(layout.DirEntry); + } + } return false; } From 019fa752a84e04245a57a55849f2c830b0b7d966 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 22:05:36 +0800 Subject: [PATCH 18/47] feat(fsops): add create + unlink glue (sits between syscall and FS) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kernel/fs/fsops.zig | 166 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/kernel/fs/fsops.zig diff --git a/src/kernel/fs/fsops.zig b/src/kernel/fs/fsops.zig new file mode 100644 index 0000000..a001f9f --- /dev/null +++ b/src/kernel/fs/fsops.zig @@ -0,0 +1,166 @@ +// src/kernel/fs/fsops.zig — Phase 3.E create + unlink glue. +// +// Bridges the syscall layer (sysOpenat O_CREAT, sysMkdirat, sysUnlinkat) +// with the FS primitives (path, dir, inode, balloc). +// +// API: +// create(path, type) -> ?*InMemInode +// - For .File: idempotent open-or-create (returns existing if a File +// at `path` already exists; null if a non-File exists there). +// - For .Dir: strictly create-new (null if anything at `path` exists). +// unlink(path) -> i32 +// - Decrements nlink; on zero, frees blocks via inode.iput truncate. +// - Refuses to unlink "." or ".." or non-empty directories. +// - Returns 0 on success, -1 on any failure. + +const std = @import("std"); +const layout = @import("layout.zig"); +const inode = @import("inode.zig"); +const path_mod = @import("path.zig"); +const dir = @import("dir.zig"); + +pub fn create(path: []const u8, itype: layout.FileType) ?*inode.InMemInode { + var leaf: [layout.DIR_NAME_LEN]u8 = undefined; + const parent = path_mod.nameiparent(path, &leaf) orelse return null; + + const leaf_slice = leafSlice(&leaf); + if (leaf_slice.len == 0) { + inode.iput(parent); + return null; + } + + inode.ilock(parent); + + // Existing entry? + if (dir.dirlookup(parent, leaf_slice)) |existing_inum| { + inode.iunlock(parent); + inode.iput(parent); + const existing_ip = inode.iget(existing_inum); + inode.ilock(existing_ip); + if (itype == .File and existing_ip.dinode.type == .File) { + inode.iunlock(existing_ip); + return existing_ip; // idempotent open-or-create for files + } + inode.iunlock(existing_ip); + inode.iput(existing_ip); + return null; + } + + const new_ip = inode.ialloc(itype) orelse { + inode.iunlock(parent); + inode.iput(parent); + return null; + }; + + // For dirs, install . and .. entries first. + if (itype == .Dir) { + inode.ilock(new_ip); + const ok_dot = dir.dirlink(new_ip, ".", @intCast(new_ip.inum)); + const ok_dotdot = dir.dirlink(new_ip, "..", @intCast(parent.inum)); + if (!ok_dot or !ok_dotdot) { + inode.iunlock(new_ip); + inode.iput(new_ip); + inode.iunlock(parent); + inode.iput(parent); + return null; + } + // Bump parent's nlink for the new "..". + parent.dinode.nlink += 1; + inode.iupdate(parent); + inode.iunlock(new_ip); + } + + if (!dir.dirlink(parent, leaf_slice, @intCast(new_ip.inum))) { + inode.iunlock(parent); + inode.iput(parent); + inode.iput(new_ip); + return null; + } + + inode.iunlock(parent); + inode.iput(parent); + return new_ip; +} + +pub fn unlink(path: []const u8) i32 { + var leaf: [layout.DIR_NAME_LEN]u8 = undefined; + const parent = path_mod.nameiparent(path, &leaf) orelse return -1; + + const leaf_slice = leafSlice(&leaf); + if (leaf_slice.len == 0 or + (leaf_slice.len == 1 and leaf_slice[0] == '.') or + (leaf_slice.len == 2 and leaf_slice[0] == '.' and leaf_slice[1] == '.')) + { + inode.iput(parent); + return -1; + } + + inode.ilock(parent); + const target_inum = dir.dirlookup(parent, leaf_slice) orelse { + inode.iunlock(parent); + inode.iput(parent); + return -1; + }; + + const target_ip = inode.iget(target_inum); + inode.ilock(target_ip); + + if (target_ip.dinode.type == .Dir and !isDirEmpty(target_ip)) { + inode.iunlock(target_ip); + inode.iput(target_ip); + inode.iunlock(parent); + inode.iput(parent); + return -1; + } + + _ = dir.dirunlink(parent, leaf_slice); + + if (target_ip.dinode.type == .Dir) { + // Drop the parent's nlink that mkdir bumped. + parent.dinode.nlink -= 1; + inode.iupdate(parent); + } + + target_ip.dinode.nlink -= 1; + inode.iupdate(target_ip); + inode.iunlock(target_ip); + inode.iput(target_ip); // triggers truncate if nlink == 0 + last ref + + inode.iunlock(parent); + inode.iput(parent); + return 0; +} + +fn leafSlice(leaf: *const [layout.DIR_NAME_LEN]u8) []const u8 { + var n: u32 = 0; + while (n < leaf.len and leaf[n] != 0) : (n += 1) {} + return leaf[0..n]; +} + +fn isDirEmpty(d: *inode.InMemInode) bool { + var off: u32 = 2 * @sizeOf(layout.DirEntry); // skip . and .. + var de: layout.DirEntry = undefined; + while (off < d.dinode.size) : (off += @sizeOf(layout.DirEntry)) { + const got = inode.readi(d, @ptrCast(&de), off, @sizeOf(layout.DirEntry)); + if (got != @sizeOf(layout.DirEntry)) break; + if (de.inum != 0) return false; + } + return true; +} + +// Host tests for the leaf helpers (run via `zig build test`). +const testing = std.testing; + +test "leafSlice trims at NUL" { + var buf: [layout.DIR_NAME_LEN]u8 = std.mem.zeroes([layout.DIR_NAME_LEN]u8); + @memcpy(buf[0..3], "abc"); + const slice = leafSlice(&buf); + try testing.expectEqual(@as(usize, 3), slice.len); + try testing.expectEqualStrings("abc", slice); +} + +test "leafSlice empty when first byte is NUL" { + const buf: [layout.DIR_NAME_LEN]u8 = std.mem.zeroes([layout.DIR_NAME_LEN]u8); + const slice = leafSlice(&buf); + try testing.expectEqual(@as(usize, 0), slice.len); +} From 518001ba8fe3a76d2b697a04b21375bf66f559fe Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 22:33:44 +0800 Subject: [PATCH 19/47] feat(syscall): sysOpenat handles O_CREAT, O_TRUNC, O_APPEND --- src/kernel/syscall.zig | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/kernel/syscall.zig b/src/kernel/syscall.zig index 5af0106..ed5a072 100644 --- a/src/kernel/syscall.zig +++ b/src/kernel/syscall.zig @@ -20,8 +20,16 @@ const file = @import("file.zig"); const console = @import("console.zig"); const inode = @import("fs/inode.zig"); const path_mod = @import("fs/path.zig"); +const fsops = @import("fs/fsops.zig"); const layout = @import("fs/layout.zig"); +pub const O_RDONLY: u32 = 0x000; +pub const O_WRONLY: u32 = 0x001; +pub const O_RDWR: u32 = 0x002; +pub const O_CREAT: u32 = 0x040; +pub const O_TRUNC: u32 = 0x200; +pub const O_APPEND: u32 = 0x400; + const SSTATUS_SUM: u32 = 1 << 18; fn setSum() void { @@ -99,16 +107,26 @@ fn sysSbrk(incr_signed: u32) u32 { return old_sz; } -/// 56 openat(dirfd, path, flags) — 3.D ignores dirfd and flags -/// (read-only existing file). Returns fd ≥ 0 or -1. +/// 56 openat(dirfd, path, flags) — handles O_CREAT, O_TRUNC, O_APPEND. +/// Returns fd ≥ 0 or -1. fn sysOpenat(dirfd: u32, path_user_va: u32, flags: u32) i32 { _ = dirfd; - _ = flags; var pbuf: [path_mod.MAX_PATH]u8 = undefined; const p = copyStrFromUser(path_user_va, &pbuf) orelse return -1; - const ip = path_mod.namei(p) orelse return -1; + // Resolve, or O_CREAT a new file. + const ip = path_mod.namei(p) orelse blk: { + if ((flags & O_CREAT) == 0) return -1; + break :blk fsops.create(p, .File) orelse return -1; + }; + + // O_TRUNC on a regular file: free all data blocks, reset size to 0. + if ((flags & O_TRUNC) != 0) { + inode.ilock(ip); + if (ip.dinode.type == .File) inode.itrunc(ip); + inode.iunlock(ip); + } const fidx = file.alloc() orelse { inode.iput(ip); @@ -116,9 +134,16 @@ fn sysOpenat(dirfd: u32, path_user_va: u32, flags: u32) i32 { }; file.ftable[fidx].type = .Inode; file.ftable[fidx].ip = ip; - file.ftable[fidx].off = 0; - // Allocate the lowest free fd in cur.ofile. + // O_APPEND: seek to EOF. + if ((flags & O_APPEND) != 0) { + inode.ilock(ip); + file.ftable[fidx].off = ip.dinode.size; + inode.iunlock(ip); + } else { + file.ftable[fidx].off = 0; + } + const cur_p = proc.cur(); var fd: u32 = 0; while (fd < proc.NOFILE) : (fd += 1) { @@ -127,8 +152,6 @@ fn sysOpenat(dirfd: u32, path_user_va: u32, flags: u32) i32 { return @intCast(fd); } } - - // No free fd — release the file table entry + inode. file.close(fidx); return -1; } From 5485ca1a6cb0a9e749d663337e98a385f253640b Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 22:39:16 +0800 Subject: [PATCH 20/47] =?UTF-8?q?style(syscall):=20zig=20fmt=20=E2=80=94?= =?UTF-8?q?=20drop=20aligned-spaces=20in=20O=5F*=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kernel/syscall.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/kernel/syscall.zig b/src/kernel/syscall.zig index ed5a072..2978aa4 100644 --- a/src/kernel/syscall.zig +++ b/src/kernel/syscall.zig @@ -25,9 +25,9 @@ const layout = @import("fs/layout.zig"); pub const O_RDONLY: u32 = 0x000; pub const O_WRONLY: u32 = 0x001; -pub const O_RDWR: u32 = 0x002; -pub const O_CREAT: u32 = 0x040; -pub const O_TRUNC: u32 = 0x200; +pub const O_RDWR: u32 = 0x002; +pub const O_CREAT: u32 = 0x040; +pub const O_TRUNC: u32 = 0x200; pub const O_APPEND: u32 = 0x400; const SSTATUS_SUM: u32 = 1 << 18; From 8351e78a3b0790f439240b3c7999706153107c0b Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 22:45:22 +0800 Subject: [PATCH 21/47] feat(syscall): add sysMkdirat (34) via fsops.create --- src/kernel/syscall.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/kernel/syscall.zig b/src/kernel/syscall.zig index 2978aa4..f73f93e 100644 --- a/src/kernel/syscall.zig +++ b/src/kernel/syscall.zig @@ -192,6 +192,16 @@ fn sysFstat(fd: u32, stat_user_va: u32) i32 { return file.fstat(idx, stat_user_va); } +/// 34 mkdirat(dirfd, path) — 3.E ignores dirfd. Returns 0 / -1. +fn sysMkdirat(dirfd: u32, path_va: u32) i32 { + _ = dirfd; + var pbuf: [path_mod.MAX_PATH]u8 = undefined; + const p = copyStrFromUser(path_va, &pbuf) orelse return -1; + const ip = fsops.create(p, .Dir) orelse return -1; + inode.iput(ip); + return 0; +} + /// Compose a new cwd_path given the current cwd_path and a relative or /// absolute target. Writes into `out` (NUL-terminated). Returns the /// length of the resulting path (excluding NUL) or null on overflow. @@ -316,6 +326,7 @@ fn sysConsoleSetMode(mode: u32) u32 { pub fn dispatch(tf: *trap.TrapFrame) void { switch (tf.a7) { 17 => tf.a0 = @bitCast(sysGetcwd(tf.a0, tf.a1)), + 34 => tf.a0 = @bitCast(sysMkdirat(tf.a0, tf.a1)), 49 => tf.a0 = @bitCast(sysChdir(tf.a0)), 56 => tf.a0 = @bitCast(sysOpenat(tf.a0, tf.a1, tf.a2)), 57 => tf.a0 = @bitCast(sysClose(tf.a0)), From b44f0bc55a8ef365a0154e676ce3750d920e9650 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 22:48:41 +0800 Subject: [PATCH 22/47] feat(syscall): add sysUnlinkat (35) via fsops.unlink --- src/kernel/syscall.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/kernel/syscall.zig b/src/kernel/syscall.zig index f73f93e..e67d4f7 100644 --- a/src/kernel/syscall.zig +++ b/src/kernel/syscall.zig @@ -202,6 +202,16 @@ fn sysMkdirat(dirfd: u32, path_va: u32) i32 { return 0; } +/// 35 unlinkat(dirfd, path, flags) — 3.E ignores dirfd and flags. +/// Returns 0 / -1. +fn sysUnlinkat(dirfd: u32, path_va: u32, flags: u32) i32 { + _ = dirfd; + _ = flags; + var pbuf: [path_mod.MAX_PATH]u8 = undefined; + const p = copyStrFromUser(path_va, &pbuf) orelse return -1; + return fsops.unlink(p); +} + /// Compose a new cwd_path given the current cwd_path and a relative or /// absolute target. Writes into `out` (NUL-terminated). Returns the /// length of the resulting path (excluding NUL) or null on overflow. @@ -327,6 +337,7 @@ pub fn dispatch(tf: *trap.TrapFrame) void { switch (tf.a7) { 17 => tf.a0 = @bitCast(sysGetcwd(tf.a0, tf.a1)), 34 => tf.a0 = @bitCast(sysMkdirat(tf.a0, tf.a1)), + 35 => tf.a0 = @bitCast(sysUnlinkat(tf.a0, tf.a1, tf.a2)), 49 => tf.a0 = @bitCast(sysChdir(tf.a0)), 56 => tf.a0 = @bitCast(sysOpenat(tf.a0, tf.a1, tf.a2)), 57 => tf.a0 = @bitCast(sysClose(tf.a0)), From 592b35ea3cf144010f3341104d082ba4f8f415ac Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 22:52:06 +0800 Subject: [PATCH 23/47] =?UTF-8?q?feat(user/lib):=20add=20start.S=20?= =?UTF-8?q?=E2=80=94=20RV32=20=5Fstart=20parses=20argc/argv=20+=20ecalls?= =?UTF-8?q?=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kernel/user/lib/start.S | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/kernel/user/lib/start.S diff --git a/src/kernel/user/lib/start.S b/src/kernel/user/lib/start.S new file mode 100644 index 0000000..a86fcff --- /dev/null +++ b/src/kernel/user/lib/start.S @@ -0,0 +1,22 @@ +# src/kernel/user/lib/start.S — Phase 3.E userland entry point. +# +# proc.exec sets sp to the System-V tail: +# sp+0: argc (u32) +# sp+4: argv[0] (u32 — pointer to first arg string) +# sp+8: argv[1] (u32) +# ... +# sp+4+4*argc: 0 (NULL terminator) +# sp+...: argv strings, NUL-terminated +# +# We pass (argc, argv) to main via a0/a1 per RV32 calling convention, +# then ecall exit on return. + +.section .text._start, "ax", @progbits +.globl _start +_start: + lw a0, 0(sp) # a0 = argc + addi a1, sp, 4 # a1 = &argv[0] + call main # main(argc, argv) -> a0 = exit status + li a7, 93 # SYS_exit + ecall +1: j 1b # never returns From d314de7281129e1c6b245c543780a37f1b683b86 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 22:56:07 +0800 Subject: [PATCH 24/47] =?UTF-8?q?feat(user/lib):=20add=20usys.S=20?= =?UTF-8?q?=E2=80=94=2019=20syscall=20stubs=20(li=20a7;=20ecall;=20ret)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kernel/user/lib/usys.S | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/kernel/user/lib/usys.S diff --git a/src/kernel/user/lib/usys.S b/src/kernel/user/lib/usys.S new file mode 100644 index 0000000..248fdf6 --- /dev/null +++ b/src/kernel/user/lib/usys.S @@ -0,0 +1,34 @@ +# src/kernel/user/lib/usys.S — Phase 3.E syscall stubs. +# +# One stub per syscall the userland calls: load syscall number into a7, +# pass-through a0..a5 (registers already populated by the Zig caller), +# ecall, return whatever the kernel put in a0. + +.macro SYSCALL name, num + .section .text.\name, "ax", @progbits + .globl \name +\name: + li a7, \num + ecall + ret +.endm + +SYSCALL getcwd, 17 +SYSCALL mkdirat, 34 +SYSCALL unlinkat, 35 +SYSCALL chdir, 49 +SYSCALL openat, 56 +SYSCALL close, 57 +SYSCALL lseek, 62 +SYSCALL read, 63 +SYSCALL write, 64 +SYSCALL fstat, 80 +SYSCALL exit, 93 +SYSCALL yield, 124 +SYSCALL getpid, 172 +SYSCALL sbrk, 214 +SYSCALL fork, 220 +SYSCALL exec, 221 +SYSCALL wait, 260 +SYSCALL set_fg_pid, 5000 +SYSCALL console_set_mode, 5001 From 713156385aa96e35a7739014d87e76ad1a8b470b Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:00:35 +0800 Subject: [PATCH 25/47] =?UTF-8?q?feat(user/lib):=20add=20ulib.zig=20?= =?UTF-8?q?=E2=80=94=20mem*/str*=20+=20syscall=20externs=20+=20Stat/O=5F*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/kernel/user/lib/ulib.zig | 121 +++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/kernel/user/lib/ulib.zig diff --git a/src/kernel/user/lib/ulib.zig b/src/kernel/user/lib/ulib.zig new file mode 100644 index 0000000..059da6f --- /dev/null +++ b/src/kernel/user/lib/ulib.zig @@ -0,0 +1,121 @@ +// src/kernel/user/lib/ulib.zig — Phase 3.E userspace standard library. +// +// All the boilerplate every user binary needs: mem*/str* helpers, syscall +// extern declarations (defined in usys.S), Stat layout, O_* flag bits, +// and a one-byte-at-a-time getline helper. + +pub fn strlen(s: [*:0]const u8) u32 { + var n: u32 = 0; + while (s[n] != 0) : (n += 1) {} + return n; +} + +pub fn strcmp(a: [*:0]const u8, b: [*:0]const u8) i32 { + var i: u32 = 0; + while (a[i] != 0 and b[i] != 0 and a[i] == b[i]) : (i += 1) {} + return @as(i32, a[i]) - @as(i32, b[i]); +} + +pub fn strncmp(a: [*]const u8, b: [*]const u8, n: u32) i32 { + var i: u32 = 0; + while (i < n) : (i += 1) { + if (a[i] != b[i]) return @as(i32, a[i]) - @as(i32, b[i]); + } + return 0; +} + +pub fn memmove(dst: [*]u8, src: [*]const u8, n: u32) void { + if (@intFromPtr(dst) < @intFromPtr(src)) { + var i: u32 = 0; + while (i < n) : (i += 1) dst[i] = src[i]; + } else { + var i: u32 = n; + while (i > 0) { + i -= 1; + dst[i] = src[i]; + } + } +} + +pub fn memset(dst: [*]u8, c: u8, n: u32) void { + var i: u32 = 0; + while (i < n) : (i += 1) dst[i] = c; +} + +pub fn memcmp(a: [*]const u8, b: [*]const u8, n: u32) i32 { + var i: u32 = 0; + while (i < n) : (i += 1) { + if (a[i] != b[i]) return @as(i32, a[i]) - @as(i32, b[i]); + } + return 0; +} + +pub fn atoi(s: [*:0]const u8) i32 { + var i: u32 = 0; + var sign: i32 = 1; + if (s[0] == '-') { + sign = -1; + i = 1; + } + var n: i32 = 0; + while (s[i] >= '0' and s[i] <= '9') : (i += 1) { + n = n * 10 + @as(i32, s[i] - '0'); + } + return sign * n; +} + +/// Read a line from `fd` into `buf`. Returns bytes read (incl. trailing `\n` +/// if present), or 0 on EOF, or -1 on error. +pub fn getline(fd: u32, buf: [*]u8, max: u32) i32 { + var n: u32 = 0; + while (n < max) { + const got = read(fd, buf + n, 1); + if (got <= 0) return if (n == 0) got else @intCast(n); + const c = buf[n]; + n += 1; + if (c == '\n') return @intCast(n); + } + return @intCast(n); +} + +// Syscall stubs (defined in usys.S — link-time symbols). +pub extern fn read(fd: u32, buf: [*]u8, n: u32) i32; +pub extern fn write(fd: u32, buf: [*]const u8, n: u32) i32; +pub extern fn close(fd: u32) i32; +pub extern fn openat(dirfd: u32, path: [*:0]const u8, flags: u32) i32; +pub extern fn lseek(fd: u32, off: i32, whence: u32) i32; +pub extern fn fstat(fd: u32, st: *Stat) i32; +pub extern fn mkdirat(dirfd: u32, path: [*:0]const u8) i32; +pub extern fn unlinkat(dirfd: u32, path: [*:0]const u8, flags: u32) i32; +pub extern fn chdir(path: [*:0]const u8) i32; +pub extern fn getcwd(buf: [*]u8, sz: u32) i32; +pub extern fn fork() i32; +pub extern fn exec(path: [*:0]const u8, argv: [*]const ?[*:0]const u8) i32; +pub extern fn wait(status: ?*i32) i32; +pub extern fn exit(status: i32) noreturn; +pub extern fn getpid() u32; +pub extern fn yield() u32; +pub extern fn sbrk(incr: i32) i32; +pub extern fn set_fg_pid(pid: u32) u32; +pub extern fn console_set_mode(mode: u32) u32; + +// Stat layout — must match kernel file.zig::Stat. +pub const Stat = extern struct { + type: u32, + size: u32, +}; + +pub const STAT_FILE: u32 = 1; +pub const STAT_DIR: u32 = 2; + +// Flag bits — must match kernel syscall.zig. +pub const O_RDONLY: u32 = 0x000; +pub const O_WRONLY: u32 = 0x001; +pub const O_RDWR: u32 = 0x002; +pub const O_CREAT: u32 = 0x040; +pub const O_TRUNC: u32 = 0x200; +pub const O_APPEND: u32 = 0x400; + +// Console modes — must match kernel console.zig. +pub const CONSOLE_COOKED: u32 = 0; +pub const CONSOLE_RAW: u32 = 1; From df2c070457bf7faef95662e87e5d774ff23890fd Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:06:00 +0800 Subject: [PATCH 26/47] =?UTF-8?q?feat(user/lib):=20add=20uprintf.zig=20?= =?UTF-8?q?=E2=80=94=20minimal=20printf(fd,=20fmt,=20args)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kernel/user/lib/uprintf.zig | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/kernel/user/lib/uprintf.zig diff --git a/src/kernel/user/lib/uprintf.zig b/src/kernel/user/lib/uprintf.zig new file mode 100644 index 0000000..dab907b --- /dev/null +++ b/src/kernel/user/lib/uprintf.zig @@ -0,0 +1,95 @@ +// src/kernel/user/lib/uprintf.zig — minimal printf for fd. +// +// Supports %d (i32), %u (u32 decimal), %x (u32 hex lowercase), +// %s (NUL-terminated string), %c (u8), %% (literal '%'). +// +// Args is a slice of the Arg union — caller passes e.g.: +// printf(1, "hello %s, pid %d\n", &.{ .{ .s = "world" }, .{ .i = pid } }); + +const ulib = @import("ulib.zig"); + +fn putc(fd: u32, c: u8) void { + var b: [1]u8 = .{c}; + _ = ulib.write(fd, &b, 1); +} + +fn putStr(fd: u32, s: [*:0]const u8) void { + var i: u32 = 0; + while (s[i] != 0) : (i += 1) putc(fd, s[i]); +} + +fn putUint(fd: u32, n: u32, base: u32) void { + var buf: [16]u8 = undefined; + var i: u32 = 0; + var v = n; + if (v == 0) { + putc(fd, '0'); + return; + } + while (v > 0) { + const d = v % base; + buf[i] = if (d < 10) @intCast('0' + d) else @intCast('a' + d - 10); + i += 1; + v /= base; + } + while (i > 0) { + i -= 1; + putc(fd, buf[i]); + } +} + +fn putInt(fd: u32, n: i32, base: u32) void { + if (n < 0) { + putc(fd, '-'); + putUint(fd, @intCast(-n), base); + } else { + putUint(fd, @intCast(n), base); + } +} + +pub const Arg = union(enum) { + i: i32, + u: u32, + s: [*:0]const u8, + c: u8, +}; + +pub fn printf(fd: u32, fmt: [*:0]const u8, args: []const Arg) void { + var i: u32 = 0; + var ai: u32 = 0; + while (fmt[i] != 0) : (i += 1) { + if (fmt[i] != '%') { + putc(fd, fmt[i]); + continue; + } + i += 1; + if (fmt[i] == 0) return; + switch (fmt[i]) { + 'd' => { + putInt(fd, args[ai].i, 10); + ai += 1; + }, + 'u' => { + putUint(fd, args[ai].u, 10); + ai += 1; + }, + 'x' => { + putUint(fd, args[ai].u, 16); + ai += 1; + }, + 's' => { + putStr(fd, args[ai].s); + ai += 1; + }, + 'c' => { + putc(fd, args[ai].c); + ai += 1; + }, + '%' => putc(fd, '%'), + else => { + putc(fd, '%'); + putc(fd, fmt[i]); + }, + } + } +} From 544e64beb38ae98a408b96c50b1cdb3bed4b2e50 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:09:28 +0800 Subject: [PATCH 27/47] fix(uprintf): handle i32.MIN in putInt without overflow --- src/kernel/user/lib/uprintf.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/kernel/user/lib/uprintf.zig b/src/kernel/user/lib/uprintf.zig index dab907b..a26a77a 100644 --- a/src/kernel/user/lib/uprintf.zig +++ b/src/kernel/user/lib/uprintf.zig @@ -41,7 +41,9 @@ fn putUint(fd: u32, n: u32, base: u32) void { fn putInt(fd: u32, n: i32, base: u32) void { if (n < 0) { putc(fd, '-'); - putUint(fd, @intCast(-n), base); + // -i32.MIN overflows i32; bit-cast through u32 to get the magnitude. + const abs: u32 = if (n == -2147483648) 2147483648 else @intCast(-n); + putUint(fd, abs, base); } else { putUint(fd, @intCast(n), base); } From 181ed0611c588d96bfcf4f28f510c90c11136a23 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:12:00 +0800 Subject: [PATCH 28/47] build: add addUserBinary helper (links start.S + usys.S + ulib + main) Co-Authored-By: Claude Sonnet 4.6 --- build.zig | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index 7b68c03..6402c61 100644 --- a/build.zig +++ b/build.zig @@ -1086,8 +1086,8 @@ pub fn build(b: *std.Build) void { }, }), }); - wasm_exe.entry = .disabled; // we call our own export, not _start - wasm_exe.rdynamic = true; // expose `export fn` symbols + wasm_exe.entry = .disabled; // we call our own export, not _start + wasm_exe.rdynamic = true; // expose `export fn` symbols const install_wasm = b.addInstallArtifact(wasm_exe, .{ .dest_dir = .{ .override = .{ .custom = "web" } }, @@ -1103,3 +1103,44 @@ pub fn build(b: *std.Build) void { wasm_step.dependOn(&install_web_hello.step); wasm_step.dependOn(&install_web_snake.step); } + +/// Build a user binary by linking start.S + usys.S + ulib.zig + uprintf.zig + +/// the binary's main.zig against user_linker.ld. Returns the executable so +/// callers can wire its install step into a build target. +fn addUserBinary( + b: *std.Build, + name: []const u8, + main_src: []const u8, + rv_target: std.Build.ResolvedTarget, +) *std.Build.Step.Compile { + // Compile the binary's main.zig as an object (its `@import("lib/ulib.zig")` etc. + // pulls ulib + uprintf in transitively). + const main_obj = b.addObject(.{ + .name = name, + .root_module = b.createModule(.{ + .root_source_file = b.path(main_src), + .target = rv_target, + .optimize = .ReleaseSmall, + .strip = false, + .single_threaded = true, + }), + }); + + const exe = b.addExecutable(.{ + .name = b.fmt("{s}.elf", .{name}), + .root_module = b.createModule(.{ + .root_source_file = null, + .target = rv_target, + .optimize = .ReleaseSmall, + .strip = false, + .single_threaded = true, + }), + }); + exe.root_module.addObject(main_obj); + exe.root_module.addAssemblyFile(b.path("src/kernel/user/lib/start.S")); + exe.root_module.addAssemblyFile(b.path("src/kernel/user/lib/usys.S")); + exe.setLinkerScript(b.path("src/kernel/user/user_linker.ld")); + exe.entry = .{ .symbol_name = "_start" }; + + return exe; +} From 2f3ff1c0eb1b57c1e1d9def117f8459820be8405 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:15:06 +0800 Subject: [PATCH 29/47] fix(build): addUserBinary takes optimize param (callers in Tasks 25-31) --- build.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index 6402c61..6ea978e 100644 --- a/build.zig +++ b/build.zig @@ -1112,6 +1112,7 @@ fn addUserBinary( name: []const u8, main_src: []const u8, rv_target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, ) *std.Build.Step.Compile { // Compile the binary's main.zig as an object (its `@import("lib/ulib.zig")` etc. // pulls ulib + uprintf in transitively). @@ -1120,7 +1121,7 @@ fn addUserBinary( .root_module = b.createModule(.{ .root_source_file = b.path(main_src), .target = rv_target, - .optimize = .ReleaseSmall, + .optimize = optimize, .strip = false, .single_threaded = true, }), @@ -1131,7 +1132,7 @@ fn addUserBinary( .root_module = b.createModule(.{ .root_source_file = null, .target = rv_target, - .optimize = .ReleaseSmall, + .optimize = optimize, .strip = false, .single_threaded = true, }), From b27d45f23238e1ff847d657e67bd0917625390fe Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:17:12 +0800 Subject: [PATCH 30/47] feat(user): add init_shell.zig (loops fork-exec-sh-wait) + build target Co-Authored-By: Claude Sonnet 4.6 --- build.zig | 11 ++++++++++ src/kernel/user/init_shell.zig | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/kernel/user/init_shell.zig diff --git a/build.zig b/build.zig index 6ea978e..b0986c7 100644 --- a/build.zig +++ b/build.zig @@ -445,6 +445,17 @@ pub fn build(b: *std.Build) void { const kernel_fs_init_step = b.step("kernel-fs-init", "Build the Phase 3.D fs_init.elf"); kernel_fs_init_step.dependOn(&install_kernel_fs_init_elf.step); + const init_shell_exe = addUserBinary( + b, + "init_shell", + "src/kernel/user/init_shell.zig", + rv_target, + .ReleaseSmall, + ); + const install_init_shell = b.addInstallFile(init_shell_exe.getEmittedBin(), "init_shell.elf"); + const kernel_init_shell_step = b.step("kernel-init-shell", "Build init_shell.elf (Phase 3.E /bin/init)"); + kernel_init_shell_step.dependOn(&install_init_shell.step); + // Phase 3.D: mkfs host tool. const mkfs_exe = b.addExecutable(.{ .name = "mkfs", diff --git a/src/kernel/user/init_shell.zig b/src/kernel/user/init_shell.zig new file mode 100644 index 0000000..6ee8eb8 --- /dev/null +++ b/src/kernel/user/init_shell.zig @@ -0,0 +1,38 @@ +// src/kernel/user/init_shell.zig — Phase 3.E /bin/init replacement. +// +// Loops forever: fork, exec /bin/sh, wait. If sh exits cleanly, restart +// it with a banner so the user knows. If exec fails (no /bin/sh), exit +// with status 127 — the kernel's halt path will catch it via the e2e +// harness. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + _ = argc; + _ = argv; + + while (true) { + const pid = ulib.fork(); + if (pid < 0) { + uprintf.printf(2, "init: fork failed\n", &.{}); + ulib.exit(127); + } + if (pid == 0) { + // Child: exec /bin/sh. + const sh_path: [*:0]const u8 = "/bin/sh"; + const sh_argv: [2]?[*:0]const u8 = .{ sh_path, null }; + _ = ulib.exec(sh_path, &sh_argv); + // exec returned — failure. + uprintf.printf(2, "init: exec /bin/sh failed\n", &.{}); + ulib.exit(127); + } + // Parent: wait for child. + var status: i32 = 0; + const reaped = ulib.wait(&status); + uprintf.printf(1, "[init] sh (pid %d) exited %d; restarting\n", &.{ + .{ .i = reaped }, + .{ .i = status }, + }); + } +} From 72f4c7efd630c7153b973b8d3961619b9e178dac Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:20:38 +0800 Subject: [PATCH 31/47] feat(user): add echo.zig + build target --- build.zig | 5 +++++ src/kernel/user/echo.zig | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/kernel/user/echo.zig diff --git a/build.zig b/build.zig index b0986c7..c9f1c52 100644 --- a/build.zig +++ b/build.zig @@ -456,6 +456,11 @@ pub fn build(b: *std.Build) void { const kernel_init_shell_step = b.step("kernel-init-shell", "Build init_shell.elf (Phase 3.E /bin/init)"); kernel_init_shell_step.dependOn(&install_init_shell.step); + const echo_exe = addUserBinary(b, "echo", "src/kernel/user/echo.zig", rv_target, .ReleaseSmall); + const install_echo = b.addInstallFile(echo_exe.getEmittedBin(), "echo.elf"); + const kernel_echo_step = b.step("kernel-echo", "Build echo.elf (Phase 3.E)"); + kernel_echo_step.dependOn(&install_echo.step); + // Phase 3.D: mkfs host tool. const mkfs_exe = b.addExecutable(.{ .name = "mkfs", diff --git a/src/kernel/user/echo.zig b/src/kernel/user/echo.zig new file mode 100644 index 0000000..3cedd65 --- /dev/null +++ b/src/kernel/user/echo.zig @@ -0,0 +1,21 @@ +// src/kernel/user/echo.zig — Phase 3.E echo utility. +// +// Writes argv[1..] joined by spaces, then a newline. + +const ulib = @import("lib/ulib.zig"); + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + var i: u32 = 1; + while (i < argc) : (i += 1) { + const arg = argv[i]; + const len = ulib.strlen(arg); + _ = ulib.write(1, @ptrCast(arg), len); + if (i + 1 < argc) { + const sp: [1]u8 = .{' '}; + _ = ulib.write(1, &sp, 1); + } + } + const nl: [1]u8 = .{'\n'}; + _ = ulib.write(1, &nl, 1); + return 0; +} From 035a0b7f1e8f9e02c5f3cfee19f02752b6bdb64e Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:25:55 +0800 Subject: [PATCH 32/47] fix(build): strip debug from user binaries (fs.img size budget) --- build.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index c9f1c52..c0041df 100644 --- a/build.zig +++ b/build.zig @@ -1138,7 +1138,10 @@ fn addUserBinary( .root_source_file = b.path(main_src), .target = rv_target, .optimize = optimize, - .strip = false, + // Strip debug sections — fs.img has 4 MB of data blocks total; + // 7 binaries × ~150 KB debug each won't fit. The kernel ELF + // loader only loads PT_LOAD segments anyway. + .strip = true, .single_threaded = true, }), }); @@ -1149,7 +1152,7 @@ fn addUserBinary( .root_source_file = null, .target = rv_target, .optimize = optimize, - .strip = false, + .strip = true, .single_threaded = true, }), }); From 09ba5b855e1a40299e7f39326e9bb24a1c187537 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:27:10 +0800 Subject: [PATCH 33/47] feat(user): add cat.zig + build target --- build.zig | 5 +++++ src/kernel/user/cat.zig | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/kernel/user/cat.zig diff --git a/build.zig b/build.zig index c0041df..6cc3758 100644 --- a/build.zig +++ b/build.zig @@ -461,6 +461,11 @@ pub fn build(b: *std.Build) void { const kernel_echo_step = b.step("kernel-echo", "Build echo.elf (Phase 3.E)"); kernel_echo_step.dependOn(&install_echo.step); + const cat_exe = addUserBinary(b, "cat", "src/kernel/user/cat.zig", rv_target, .ReleaseSmall); + const install_cat = b.addInstallFile(cat_exe.getEmittedBin(), "cat.elf"); + const kernel_cat_step = b.step("kernel-cat", "Build cat.elf (Phase 3.E)"); + kernel_cat_step.dependOn(&install_cat.step); + // Phase 3.D: mkfs host tool. const mkfs_exe = b.addExecutable(.{ .name = "mkfs", diff --git a/src/kernel/user/cat.zig b/src/kernel/user/cat.zig new file mode 100644 index 0000000..89285ba --- /dev/null +++ b/src/kernel/user/cat.zig @@ -0,0 +1,42 @@ +// src/kernel/user/cat.zig — Phase 3.E cat utility. +// +// With no args: copy fd 0 → fd 1 until EOF. +// With args: open each, copy contents to fd 1, close. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +const BUF_SIZE: u32 = 512; +var buf: [BUF_SIZE]u8 = undefined; + +fn copyFd(fd: u32) void { + while (true) { + const got = ulib.read(fd, &buf, BUF_SIZE); + if (got <= 0) break; + var written: u32 = 0; + while (written < @as(u32, @intCast(got))) { + const w = ulib.write(1, buf[written..].ptr, @as(u32, @intCast(got)) - written); + if (w <= 0) break; + written += @intCast(w); + } + } +} + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + if (argc < 2) { + copyFd(0); + return 0; + } + + var i: u32 = 1; + while (i < argc) : (i += 1) { + const fd = ulib.openat(0, argv[i], ulib.O_RDONLY); + if (fd < 0) { + uprintf.printf(2, "cat: cannot open %s\n", &.{.{ .s = argv[i] }}); + continue; + } + copyFd(@intCast(fd)); + _ = ulib.close(@intCast(fd)); + } + return 0; +} From 3215aa85034f4684cb803de48234485d31b7963a Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:33:16 +0800 Subject: [PATCH 34/47] feat(user): add ls.zig + build target Adds src/kernel/user/ls.zig (~73 LoC): lists directory entries via DirEntry reads or prints file path+size for regular files. Wires the kernel-ls build step in build.zig after kernel-cat. Co-Authored-By: Claude Opus 4.7 (1M context) --- build.zig | 5 +++ src/kernel/user/ls.zig | 73 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/kernel/user/ls.zig diff --git a/build.zig b/build.zig index 6cc3758..4e6515c 100644 --- a/build.zig +++ b/build.zig @@ -466,6 +466,11 @@ pub fn build(b: *std.Build) void { const kernel_cat_step = b.step("kernel-cat", "Build cat.elf (Phase 3.E)"); kernel_cat_step.dependOn(&install_cat.step); + const ls_exe = addUserBinary(b, "ls", "src/kernel/user/ls.zig", rv_target, .ReleaseSmall); + const install_ls = b.addInstallFile(ls_exe.getEmittedBin(), "ls.elf"); + const kernel_ls_step = b.step("kernel-ls", "Build ls.elf (Phase 3.E)"); + kernel_ls_step.dependOn(&install_ls.step); + // Phase 3.D: mkfs host tool. const mkfs_exe = b.addExecutable(.{ .name = "mkfs", diff --git a/src/kernel/user/ls.zig b/src/kernel/user/ls.zig new file mode 100644 index 0000000..fc064e1 --- /dev/null +++ b/src/kernel/user/ls.zig @@ -0,0 +1,73 @@ +// src/kernel/user/ls.zig — Phase 3.E ls utility. +// +// With no args: list current directory. +// With args: for each path, fstat to determine type: +// - Dir: read DirEntry records, print each non-zero name. +// - File: print the path itself + size. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +// Must match kernel fs/layout.zig: u16 inum + 14-byte name = 16 B total. +const DIR_NAME_LEN: u32 = 14; +const DirEntry = extern struct { + inum: u16, + name: [DIR_NAME_LEN]u8, +}; + +fn printName(name: *const [DIR_NAME_LEN]u8) void { + var n: u32 = 0; + while (n < DIR_NAME_LEN and name[n] != 0) : (n += 1) {} + _ = ulib.write(1, name, n); + const nl: [1]u8 = .{'\n'}; + _ = ulib.write(1, &nl, 1); +} + +fn lsPath(path: [*:0]const u8) void { + const fd = ulib.openat(0, path, ulib.O_RDONLY); + if (fd < 0) { + uprintf.printf(2, "ls: cannot open %s\n", &.{.{ .s = path }}); + return; + } + defer _ = ulib.close(@intCast(fd)); + + var st: ulib.Stat = .{ .type = 0, .size = 0 }; + if (ulib.fstat(@intCast(fd), &st) < 0) { + uprintf.printf(2, "ls: cannot stat %s\n", &.{.{ .s = path }}); + return; + } + + if (st.type == ulib.STAT_FILE) { + // Print the path itself; ls(1) on Linux prints just the basename + // when given a file, but our 1-arg ls just echoes whatever the + // user passed. + uprintf.printf(1, "%s %u\n", &.{ .{ .s = path }, .{ .u = st.size } }); + return; + } + + if (st.type != ulib.STAT_DIR) { + uprintf.printf(2, "ls: %s: unknown type\n", &.{.{ .s = path }}); + return; + } + + var de: DirEntry = .{ .inum = 0, .name = [_]u8{0} ** DIR_NAME_LEN }; + while (true) { + const got = ulib.read(@intCast(fd), @ptrCast(&de), @sizeOf(DirEntry)); + if (got != @sizeOf(DirEntry)) break; + if (de.inum == 0) continue; + printName(&de.name); + } +} + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + if (argc < 2) { + const dot: [*:0]const u8 = "."; + lsPath(dot); + return 0; + } + var i: u32 = 1; + while (i < argc) : (i += 1) { + lsPath(argv[i]); + } + return 0; +} From ae8813e1c8f3bc42ebc108d55bac426295d13506 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:37:31 +0800 Subject: [PATCH 35/47] feat(user): add mkdir.zig + build target --- build.zig | 5 +++++ src/kernel/user/mkdir.zig | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/kernel/user/mkdir.zig diff --git a/build.zig b/build.zig index 4e6515c..359c17b 100644 --- a/build.zig +++ b/build.zig @@ -471,6 +471,11 @@ pub fn build(b: *std.Build) void { const kernel_ls_step = b.step("kernel-ls", "Build ls.elf (Phase 3.E)"); kernel_ls_step.dependOn(&install_ls.step); + const mkdir_exe = addUserBinary(b, "mkdir", "src/kernel/user/mkdir.zig", rv_target, .ReleaseSmall); + const install_mkdir = b.addInstallFile(mkdir_exe.getEmittedBin(), "mkdir.elf"); + const kernel_mkdir_step = b.step("kernel-mkdir", "Build mkdir.elf (Phase 3.E)"); + kernel_mkdir_step.dependOn(&install_mkdir.step); + // Phase 3.D: mkfs host tool. const mkfs_exe = b.addExecutable(.{ .name = "mkfs", diff --git a/src/kernel/user/mkdir.zig b/src/kernel/user/mkdir.zig new file mode 100644 index 0000000..5e4425a --- /dev/null +++ b/src/kernel/user/mkdir.zig @@ -0,0 +1,16 @@ +// src/kernel/user/mkdir.zig — Phase 3.E mkdir utility. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + if (argc < 2) { + uprintf.printf(2, "usage: mkdir \n", &.{}); + return 1; + } + if (ulib.mkdirat(0, argv[1]) < 0) { + uprintf.printf(2, "mkdir: cannot create %s\n", &.{.{ .s = argv[1] }}); + return 1; + } + return 0; +} From 6dd7901f0bfa9a7955463cc1fb89ae5fddc30924 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:40:25 +0800 Subject: [PATCH 36/47] feat(user): add rm.zig + build target --- build.zig | 5 +++++ src/kernel/user/rm.zig | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/kernel/user/rm.zig diff --git a/build.zig b/build.zig index 359c17b..0398bcd 100644 --- a/build.zig +++ b/build.zig @@ -476,6 +476,11 @@ pub fn build(b: *std.Build) void { const kernel_mkdir_step = b.step("kernel-mkdir", "Build mkdir.elf (Phase 3.E)"); kernel_mkdir_step.dependOn(&install_mkdir.step); + const rm_exe = addUserBinary(b, "rm", "src/kernel/user/rm.zig", rv_target, .ReleaseSmall); + const install_rm = b.addInstallFile(rm_exe.getEmittedBin(), "rm.elf"); + const kernel_rm_step = b.step("kernel-rm", "Build rm.elf (Phase 3.E)"); + kernel_rm_step.dependOn(&install_rm.step); + // Phase 3.D: mkfs host tool. const mkfs_exe = b.addExecutable(.{ .name = "mkfs", diff --git a/src/kernel/user/rm.zig b/src/kernel/user/rm.zig new file mode 100644 index 0000000..cda7d05 --- /dev/null +++ b/src/kernel/user/rm.zig @@ -0,0 +1,16 @@ +// src/kernel/user/rm.zig — Phase 3.E rm utility. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + if (argc < 2) { + uprintf.printf(2, "usage: rm \n", &.{}); + return 1; + } + if (ulib.unlinkat(0, argv[1], 0) < 0) { + uprintf.printf(2, "rm: cannot remove %s\n", &.{.{ .s = argv[1] }}); + return 1; + } + return 0; +} From f13973eb4f1d71044bb7dfbc5d7e13120f3112f1 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:48:04 +0800 Subject: [PATCH 37/47] =?UTF-8?q?feat(user):=20add=20sh.zig=20=E2=80=94=20?= =?UTF-8?q?line/token/redirect/builtins/fork+exec=20+=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- build.zig | 5 + src/kernel/user/sh.zig | 248 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/kernel/user/sh.zig diff --git a/build.zig b/build.zig index 0398bcd..0a1729e 100644 --- a/build.zig +++ b/build.zig @@ -481,6 +481,11 @@ pub fn build(b: *std.Build) void { const kernel_rm_step = b.step("kernel-rm", "Build rm.elf (Phase 3.E)"); kernel_rm_step.dependOn(&install_rm.step); + const sh_exe = addUserBinary(b, "sh", "src/kernel/user/sh.zig", rv_target, .ReleaseSmall); + const install_sh = b.addInstallFile(sh_exe.getEmittedBin(), "sh.elf"); + const kernel_sh_step = b.step("kernel-sh", "Build sh.elf (Phase 3.E)"); + kernel_sh_step.dependOn(&install_sh.step); + // Phase 3.D: mkfs host tool. const mkfs_exe = b.addExecutable(.{ .name = "mkfs", diff --git a/src/kernel/user/sh.zig b/src/kernel/user/sh.zig new file mode 100644 index 0000000..eff61ce --- /dev/null +++ b/src/kernel/user/sh.zig @@ -0,0 +1,248 @@ +// src/kernel/user/sh.zig — Phase 3.E shell. +// +// Loop: +// - Print "$ " prompt to fd 1. +// - Read a line from fd 0 (terminated by \n thanks to the kernel +// console line discipline). +// - Tokenize on whitespace; recognize `<`, `>`, `>>` as redirect tokens. +// - If first token is `cd` / `pwd` / `exit`, handle inline. +// - Else: fork; in child, apply redirects (close target fd, open file at +// same fd via openat which returns lowest free fd — we close-then-open +// to land at the target fd); exec the binary. In parent, set_fg_pid +// (child), wait, set_fg_pid(0). +// +// Exec path resolution: if argv[0] starts with "/", use as-is. Else prepend +// "/bin/" so `ls` becomes `/bin/ls`. + +const ulib = @import("lib/ulib.zig"); +const uprintf = @import("lib/uprintf.zig"); + +const LINE_MAX: u32 = 256; +const MAX_TOKENS: u32 = 32; +const PATH_MAX: u32 = 256; + +var line_buf: [LINE_MAX]u8 = undefined; +var argv_storage: [MAX_TOKENS][PATH_MAX]u8 = undefined; +var argv_ptrs: [MAX_TOKENS + 1]?[*:0]const u8 = undefined; +var path_buf: [PATH_MAX]u8 = undefined; + +const RedirectKind = enum { None, In, Out, Append }; + +const ParsedCmd = struct { + argc: u32, + redir_kind: RedirectKind, + redir_target: ?[*:0]const u8, +}; + +fn isSpace(c: u8) bool { + return c == ' ' or c == '\t' or c == '\n'; +} + +fn isRedirChar(c: u8) bool { + return c == '<' or c == '>'; +} + +/// Tokenize `line` (NUL-terminated, length n) into argv_storage + parse a +/// single redirect (if any). Returns the parsed result. argc is the count +/// of "real" argv tokens (not including the redirect file). +fn parseLine(line: [*]const u8, n: u32) ParsedCmd { + var i: u32 = 0; + var argc: u32 = 0; + var result: ParsedCmd = .{ .argc = 0, .redir_kind = .None, .redir_target = null }; + + while (i < n) { + // Skip whitespace. + while (i < n and isSpace(line[i])) : (i += 1) {} + if (i >= n) break; + + // Redirect? + if (isRedirChar(line[i])) { + var kind: RedirectKind = .Out; + if (line[i] == '<') kind = .In; + i += 1; + if (kind == .Out and i < n and line[i] == '>') { + kind = .Append; + i += 1; + } + // Skip whitespace, then capture target. + while (i < n and isSpace(line[i])) : (i += 1) {} + const target_start = i; + while (i < n and !isSpace(line[i]) and !isRedirChar(line[i])) : (i += 1) {} + const target_len = i - target_start; + if (target_len == 0 or target_len >= PATH_MAX) { + uprintf.printf(2, "sh: missing redirect target\n", &.{}); + return .{ .argc = 0, .redir_kind = .None, .redir_target = null }; + } + // Stash target into the last argv slot (we won't pass it to exec). + const slot = MAX_TOKENS - 1; + var k: u32 = 0; + while (k < target_len) : (k += 1) argv_storage[slot][k] = line[target_start + k]; + argv_storage[slot][target_len] = 0; + result.redir_kind = kind; + result.redir_target = @ptrCast(&argv_storage[slot][0]); + continue; + } + + // Plain token. + if (argc >= MAX_TOKENS - 1) { + uprintf.printf(2, "sh: too many args\n", &.{}); + return .{ .argc = 0, .redir_kind = .None, .redir_target = null }; + } + const start = i; + while (i < n and !isSpace(line[i]) and !isRedirChar(line[i])) : (i += 1) {} + const tok_len = i - start; + if (tok_len >= PATH_MAX) { + uprintf.printf(2, "sh: token too long\n", &.{}); + return .{ .argc = 0, .redir_kind = .None, .redir_target = null }; + } + var k: u32 = 0; + while (k < tok_len) : (k += 1) argv_storage[argc][k] = line[start + k]; + argv_storage[argc][tok_len] = 0; + argv_ptrs[argc] = @ptrCast(&argv_storage[argc][0]); + argc += 1; + } + + argv_ptrs[argc] = null; + result.argc = argc; + return result; +} + +/// Resolve the binary path: if argv[0] starts with "/", use as-is; else +/// prepend "/bin/". Writes the result into `path_buf` (NUL-terminated). +fn resolveBin(name: [*:0]const u8) [*:0]const u8 { + if (name[0] == '/') return name; + var i: u32 = 0; + const prefix = "/bin/"; + while (i < prefix.len) : (i += 1) path_buf[i] = prefix[i]; + var j: u32 = 0; + while (name[j] != 0 and i + j + 1 < PATH_MAX) : (j += 1) path_buf[i + j] = name[j]; + path_buf[i + j] = 0; + return @ptrCast(&path_buf[0]); +} + +fn doRedirect(kind: RedirectKind, target: [*:0]const u8) bool { + switch (kind) { + .None => return true, + .In => { + _ = ulib.close(0); + const fd = ulib.openat(0, target, ulib.O_RDONLY); + if (fd != 0) { + uprintf.printf(2, "sh: redir < %s failed\n", &.{.{ .s = target }}); + return false; + } + return true; + }, + .Out => { + _ = ulib.close(1); + const fd = ulib.openat(0, target, ulib.O_WRONLY | ulib.O_CREAT | ulib.O_TRUNC); + if (fd != 1) { + uprintf.printf(2, "sh: redir > %s failed\n", &.{.{ .s = target }}); + return false; + } + return true; + }, + .Append => { + _ = ulib.close(1); + const fd = ulib.openat(0, target, ulib.O_WRONLY | ulib.O_CREAT | ulib.O_APPEND); + if (fd != 1) { + uprintf.printf(2, "sh: redir >> %s failed\n", &.{.{ .s = target }}); + return false; + } + return true; + }, + } +} + +fn handleBuiltin(parsed: *const ParsedCmd) bool { + if (parsed.argc == 0) return false; + const cmd = argv_ptrs[0].?; + + if (ulib.strcmp(cmd, "exit") == 0) { + ulib.exit(0); + } + if (ulib.strcmp(cmd, "cd") == 0) { + if (parsed.argc < 2) { + uprintf.printf(2, "cd: missing arg\n", &.{}); + return true; + } + if (ulib.chdir(argv_ptrs[1].?) < 0) { + uprintf.printf(2, "cd: %s: no such directory\n", &.{.{ .s = argv_ptrs[1].? }}); + } + return true; + } + if (ulib.strcmp(cmd, "pwd") == 0) { + var cwd_buf: [PATH_MAX]u8 = undefined; + const len = ulib.getcwd(&cwd_buf, PATH_MAX); + if (len < 0) { + uprintf.printf(2, "pwd: getcwd failed\n", &.{}); + return true; + } + _ = ulib.write(1, &cwd_buf, @intCast(len)); + const nl: [1]u8 = .{'\n'}; + _ = ulib.write(1, &nl, 1); + return true; + } + return false; +} + +fn runCommand(parsed: *const ParsedCmd) void { + if (parsed.argc == 0) return; + if (handleBuiltin(parsed)) return; + + const pid = ulib.fork(); + if (pid < 0) { + uprintf.printf(2, "sh: fork failed\n", &.{}); + return; + } + if (pid == 0) { + // Child. + if (parsed.redir_kind != .None) { + if (!doRedirect(parsed.redir_kind, parsed.redir_target.?)) ulib.exit(1); + } + const path = resolveBin(argv_ptrs[0].?); + _ = ulib.exec(path, @ptrCast(&argv_ptrs)); + uprintf.printf(2, "sh: exec %s failed\n", &.{.{ .s = path }}); + ulib.exit(127); + } + // Parent. + _ = ulib.set_fg_pid(@intCast(pid)); + var status: i32 = 0; + const waited = ulib.wait(&status); + _ = waited; + _ = ulib.set_fg_pid(0); +} + +export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { + _ = argc; + _ = argv; + + while (true) { + // Prompt. + const prompt: [2]u8 = .{ '$', ' ' }; + _ = ulib.write(1, &prompt, 2); + + // Read a line. + const got = ulib.getline(0, &line_buf, LINE_MAX); + if (got <= 0) { + // EOF on stdin: bail. + const nl: [1]u8 = .{'\n'}; + _ = ulib.write(1, &nl, 1); + return 0; + } + + const n: u32 = @intCast(got); + // Skip blank lines. + var blank = true; + var i: u32 = 0; + while (i < n) : (i += 1) { + if (!isSpace(line_buf[i])) { + blank = false; + break; + } + } + if (blank) continue; + + const parsed = parseLine(&line_buf, n); + runCommand(&parsed); + } +} From a8333945a674635207d400b7f82b18032d2708a4 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:55:43 +0800 Subject: [PATCH 38/47] docs(sh): explain dirfd=0 sentinel in doRedirect --- src/kernel/user/sh.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/kernel/user/sh.zig b/src/kernel/user/sh.zig index eff61ce..784c92d 100644 --- a/src/kernel/user/sh.zig +++ b/src/kernel/user/sh.zig @@ -121,6 +121,9 @@ fn resolveBin(name: [*:0]const u8) [*:0]const u8 { } fn doRedirect(kind: RedirectKind, target: [*:0]const u8) bool { + // dirfd=0 in the openat calls below is a sentinel: sysOpenat ignores + // dirfd entirely in Phase 3.E (paths resolve from cwd unconditionally). + // If dirfd ever becomes meaningful, pass an explicit AT_FDCWD instead. switch (kind) { .None => return true, .In => { From e822dfdbcf6fc67c99e06529eecae8eacb5c5664 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Sun, 26 Apr 2026 23:58:45 +0800 Subject: [PATCH 39/47] build(mkfs): skip dot-files, recurse empty dirs, --init flag - populateFromDir: skip entries whose name starts with '.' (.gitkeep, .DS_Store) - directory branch: comment confirms Dir inode is created even for empty subdirs (logic was already correct) - CLI: add --init flag; when given, installs that binary as /bin/init after the --bin walk Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kernel/mkfs.zig | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/kernel/mkfs.zig b/src/kernel/mkfs.zig index c6d692d..5b92353 100644 --- a/src/kernel/mkfs.zig +++ b/src/kernel/mkfs.zig @@ -168,6 +168,8 @@ const ImageBuilder = struct { fn populateFromDir(io: Io, builder: *ImageBuilder, dir_inum: u32, dir: Io.Dir, gpa: std.mem.Allocator) !void { var it = dir.iterate(); while (try it.next(io)) |entry| { + // Skip dot-files (.gitkeep, .DS_Store, etc.). + if (entry.name.len > 0 and entry.name[0] == '.') continue; switch (entry.kind) { .file => { var f = try dir.openFile(io, entry.name, .{}); @@ -179,6 +181,8 @@ fn populateFromDir(io: Io, builder: *ImageBuilder, dir_inum: u32, dir: Io.Dir, g try builder.createFile(dir_inum, entry.name, buf); }, .directory => { + // Create the Dir inode and link it into the parent — even if + // the subdirectory is empty (e.g. tmp/ with only a .gitkeep). const sub_inum = try builder.createDir(dir_inum); try builder.appendDirEntry(dir_inum, entry.name, sub_inum); var sub_dir = try dir.openDir(io, entry.name, .{ .iterate = true }); @@ -204,6 +208,7 @@ pub fn main(init: std.process.Init) !void { var root_path: ?[]const u8 = null; var bin_path: ?[]const u8 = null; var out_path: ?[]const u8 = null; + var init_path: ?[]const u8 = null; var i: usize = 1; while (i < argv.len) { if (std.mem.eql(u8, argv[i], "--root") and i + 1 < argv.len) { @@ -215,6 +220,9 @@ pub fn main(init: std.process.Init) !void { } else if (std.mem.eql(u8, argv[i], "--out") and i + 1 < argv.len) { out_path = argv[i + 1]; i += 2; + } else if (std.mem.eql(u8, argv[i], "--init") and i + 1 < argv.len) { + init_path = argv[i + 1]; + i += 2; } else { stderr.print("mkfs: unexpected arg {s}\n", .{argv[i]}) catch {}; stderr.flush() catch {}; @@ -222,7 +230,7 @@ pub fn main(init: std.process.Init) !void { } } if (root_path == null or bin_path == null or out_path == null) { - stderr.print("usage: mkfs --root --bin --out \n", .{}) catch {}; + stderr.print("usage: mkfs --root --bin --out [--init ]\n", .{}) catch {}; stderr.flush() catch {}; std.process.exit(2); } @@ -274,6 +282,22 @@ pub fn main(init: std.process.Init) !void { defer bin_dir.close(io); try populateFromDir(io, builder, bin_inum, bin_dir, gpa); + // If --init was given, install that binary as /bin/init (overrides + // any "init" file already present from --bin walk). + if (init_path) |ipath| { + var init_f = Io.Dir.cwd().openFile(io, ipath, .{}) catch |err| { + stderr.print("mkfs: cannot open --init {s}: {s}\n", .{ ipath, @errorName(err) }) catch {}; + stderr.flush() catch {}; + std.process.exit(1); + }; + defer init_f.close(io); + const sz = try init_f.length(io); + const buf = try gpa.alloc(u8, sz); + defer gpa.free(buf); + _ = try init_f.readPositionalAll(io, buf, 0); + try builder.createFile(bin_inum, "init", buf); + } + builder.finalize(); // Write the image. From 37ce265b86673c924515a1e39f2144b878424352 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 00:03:14 +0800 Subject: [PATCH 40/47] build: add shell-fs-img target + shell-fs/ staging (motd + empty tmp) Co-Authored-By: Claude Sonnet 4.6 --- build.zig | 24 +++++++++++++++++++++++ src/kernel/userland/shell-fs/etc/motd | 1 + src/kernel/userland/shell-fs/tmp/.gitkeep | 0 3 files changed, 25 insertions(+) create mode 100644 src/kernel/userland/shell-fs/etc/motd create mode 100644 src/kernel/userland/shell-fs/tmp/.gitkeep diff --git a/build.zig b/build.zig index 0a1729e..fa72e7e 100644 --- a/build.zig +++ b/build.zig @@ -516,6 +516,30 @@ pub fn build(b: *std.Build) void { const fs_img_step = b.step("fs-img", "Build fs.img from staged userland + mkfs"); fs_img_step.dependOn(&install_fs_img.step); + // Phase 3.E: shell-fs.img — install init_shell as /bin/init plus the + // six utility binaries (sh, ls, cat, echo, mkdir, rm). The shell-fs/ + // staging tree carries /etc/motd and the empty /tmp/ directory. + const shell_fs_bin_stage = b.addWriteFiles(); + _ = shell_fs_bin_stage.addCopyFile(init_shell_exe.getEmittedBin(), "init"); + _ = shell_fs_bin_stage.addCopyFile(sh_exe.getEmittedBin(), "sh"); + _ = shell_fs_bin_stage.addCopyFile(ls_exe.getEmittedBin(), "ls"); + _ = shell_fs_bin_stage.addCopyFile(cat_exe.getEmittedBin(), "cat"); + _ = shell_fs_bin_stage.addCopyFile(echo_exe.getEmittedBin(), "echo"); + _ = shell_fs_bin_stage.addCopyFile(mkdir_exe.getEmittedBin(), "mkdir"); + _ = shell_fs_bin_stage.addCopyFile(rm_exe.getEmittedBin(), "rm"); + + const shell_fs_img_run = b.addRunArtifact(mkfs_exe); + shell_fs_img_run.addArg("--root"); + shell_fs_img_run.addDirectoryArg(b.path("src/kernel/userland/shell-fs")); + shell_fs_img_run.addArg("--bin"); + shell_fs_img_run.addDirectoryArg(shell_fs_bin_stage.getDirectory()); + shell_fs_img_run.addArg("--out"); + const shell_fs_img = shell_fs_img_run.addOutputFileArg("shell-fs.img"); + + const install_shell_fs_img = b.addInstallFile(shell_fs_img, "shell-fs.img"); + const shell_fs_img_step = b.step("shell-fs-img", "Build shell-fs.img with all Phase 3.E binaries"); + shell_fs_img_step.dependOn(&install_shell_fs_img.step); + const multi_boot_config_stub_dir = b.addWriteFiles(); const multi_boot_config_zig = multi_boot_config_stub_dir.add( "boot_config.zig", diff --git a/src/kernel/userland/shell-fs/etc/motd b/src/kernel/userland/shell-fs/etc/motd new file mode 100644 index 0000000..23ed2a9 --- /dev/null +++ b/src/kernel/userland/shell-fs/etc/motd @@ -0,0 +1 @@ +hello from phase 3 diff --git a/src/kernel/userland/shell-fs/tmp/.gitkeep b/src/kernel/userland/shell-fs/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 From e1721ba826c6afe6cde47fac55005ed9ad56bc29 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 10:58:33 +0800 Subject: [PATCH 41/47] fix(mkfs): iterate all top-level subdirs of --root, not just /etc Plan 3.D's mkfs hard-coded /etc as the only subdirectory under root and silently dropped any other entries. The shell-fs/ staging tree adds /tmp as an empty directory carrier, so mkfs now walks every top-level entry, creating an inode for each subdirectory regardless of whether it has children. /bin is still wired separately via --bin and skipped here. Closes the gap left by the earlier mkfs commit (e822dfd) which addressed dot-file skipping + empty-dir handling but kept the /etc-only walker. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kernel/mkfs.zig | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/kernel/mkfs.zig b/src/kernel/mkfs.zig index 5b92353..bb511e0 100644 --- a/src/kernel/mkfs.zig +++ b/src/kernel/mkfs.zig @@ -252,25 +252,31 @@ pub fn main(init: std.process.Init) !void { try builder.appendDirEntry(root_inum, ".", root_inum); try builder.appendDirEntry(root_inum, "..", root_inum); - // /etc + /bin subdirectories. - const etc_inum = try builder.createDir(root_inum); - try builder.appendDirEntry(root_inum, "etc", etc_inum); + // /bin subdirectory (always created). const bin_inum = try builder.createDir(root_inum); try builder.appendDirEntry(root_inum, "bin", bin_inum); - // Walk --root: every file goes into /etc (3.D simplification — only - // /etc is supported; the spec eventually expands to /var, /tmp, etc., - // but 3.D's e2e only needs /etc/motd). + // Walk --root: iterate all top-level subdirectories and create them + // under root. Files directly in --root are ignored (only dirs). + // The /bin dir above is wired separately via --bin; skip it here. var root_dir = Io.Dir.cwd().openDir(io, root_path.?, .{ .iterate = true }) catch |err| { stderr.print("mkfs: cannot open --root {s}: {s}\n", .{ root_path.?, @errorName(err) }) catch {}; stderr.flush() catch {}; std.process.exit(1); }; defer root_dir.close(io); - var etc_dir_opt: ?Io.Dir = root_dir.openDir(io, "etc", .{ .iterate = true }) catch null; - if (etc_dir_opt) |*d| { - defer d.close(io); - try populateFromDir(io, builder, etc_inum, d.*, gpa); + { + var it = root_dir.iterate(); + while (try it.next(io)) |entry| { + if (entry.name.len > 0 and entry.name[0] == '.') continue; // skip dot-files + if (entry.kind != .directory) continue; + if (std.mem.eql(u8, entry.name, "bin")) continue; // handled via --bin + const sub_inum = try builder.createDir(root_inum); + try builder.appendDirEntry(root_inum, entry.name, sub_inum); + var sub_dir = try root_dir.openDir(io, entry.name, .{ .iterate = true }); + defer sub_dir.close(io); + try populateFromDir(io, builder, sub_inum, sub_dir, gpa); + } } // Walk --bin: every file goes into /bin. From b9b181c2a8b4a8384844e8e7e10028832af7a336 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 10:58:39 +0800 Subject: [PATCH 42/47] fix(user/init_shell): exit cleanly when sh exits 0 Without this, init's restart loop would keep re-spawning sh after each clean exit, including the shell's `exit` builtin path. The e2e harness needs init to halt cleanly so the kernel can write the final "ticks observed" trailer and stop the emulator. Restart-on-nonzero remains, so a crash still gets the diagnostic + restart. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kernel/user/init_shell.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/kernel/user/init_shell.zig b/src/kernel/user/init_shell.zig index 6ee8eb8..636d5c8 100644 --- a/src/kernel/user/init_shell.zig +++ b/src/kernel/user/init_shell.zig @@ -30,6 +30,9 @@ export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 { // Parent: wait for child. var status: i32 = 0; const reaped = ulib.wait(&status); + // If sh exited cleanly (status 0), shut down rather than restart. + // This lets the e2e harness observe a clean halt after `exit`. + if (status == 0) ulib.exit(0); uprintf.printf(1, "[init] sh (pid %d) exited %d; restarting\n", &.{ .{ .i = reaped }, .{ .i = status }, From 30a076ef6b3caae96537434ee2cbb902af9e6932 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 10:59:00 +0800 Subject: [PATCH 43/47] fix(kernel,emulator): WFI in scheduler idle path + paced UART RX drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 3.E's e2e-shell needs `--input` bytes to flow into the UART RX FIFO while the shell is sleeping in console.read. The 3.D scheduler's "open SIE briefly" busy-poll never invokes cpu.idleSpin (which is the only drain site for the rx_pump), so without changes the shell never sees its keystrokes. Adding WFI to the idle window fixes that — but on its own creates a fixed-point loop: cpu.step's prologue raises the pending S-trap before WFI is fetched, sret returns to WFI, and the same trap fires again on the next step. Three coordinated changes break the loop and make the delivery cadence right: - sched.zig: WFI inserted between the SIE csrs/csrc pair so the emulator actually pauses and runs cpu.idleSpin. - trap.zig: s_kernel_trap_dispatch advances sepc by 4 before sret. That's safe because the only call site is the scheduler's WFI window — sepc points at a 4-byte WFI (or, if the trap fired on the boundary just before WFI, the 4-byte csrs that precedes it; either way, advancing skips ahead to the trailing csrc which closes the window). Without the advance, sret re-executes WFI and the scheduler can never re-scan ptable. - cpu.zig idleSpin: check_interrupt now fires BEFORE draining the rx_pump, so disk-I/O sleeps (block IRQ pending) take their trap immediately and don't leak --input bytes into the FIFO mid-boot. Drain only happens when the guest is truly idle — i.e., the shell prompt is waiting in read(). And drain is now one byte per iteration (uart.RxPump.drainOne) so cooked-mode console echo interleaves with the shell's per-line prompts instead of bulk-emitting every echo before the second prompt prints. Regressions checked: e2e-fs / e2e-fork / e2e-snake / e2e-plic-block all still pass. e2e-multiproc-stub remains broken on Task-33 baseline (a separate pre-existing issue with the Phase 2 kmain branch not setting up the console file table). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/emulator/cpu.zig | 51 ++++++++++++++++++++++++++++------- src/emulator/devices/uart.zig | 17 ++++++++++++ src/kernel/sched.zig | 27 ++++++++++++------- src/kernel/trap.zig | 18 +++++++++++++ 4 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src/emulator/cpu.zig b/src/emulator/cpu.zig index 2abf7d8..627155b 100644 --- a/src/emulator/cpu.zig +++ b/src/emulator/cpu.zig @@ -270,11 +270,23 @@ pub const Cpu = struct { self.memory.block.pending_irq = false; self.memory.block.last_op = null; } + // Take any immediately-deliverable interrupt before draining + // input. This covers the just-asserted block IRQ as well as + // a pending timer SSI. Returning early means we DON'T drain + // `--input` bytes during disk-I/O phases — drain happens + // only when the guest has no other work pending (e.g., the + // shell prompt is waiting in console.read). + if (check_interrupt(self)) return; // Drain host stdin if a pump is configured (Task 16 wires this). + // One byte per iteration — a busy-polling guest observes each + // `--input` byte as a separate keystroke, with the cooked-mode + // console echoing each byte before the next arrives. Bulk + // drains would emit every echo at once, racing the shell's + // prompt printing. if (self.memory.uart.rx_pump) |pump| { - pump.drainAvailable(self.memory.io, self.memory.uart); + pump.drainOne(self.memory.io, self.memory.uart); } - // Did we just get something interrupt-worthy? + // Fire any UART-RX-induced trap from the just-pushed byte. if (check_interrupt(self)) return; if (monotonicNs() - start > max_ns) return; @@ -540,8 +552,15 @@ fn cpuRig() !*CpuRig { rig.plic = plic_dev.Plic.init(); rig.block = block_dev_t.Block.init(); rig.mem = try Memory.init( - std.testing.allocator, &rig.halt, &rig.uart, &rig.clint, &rig.plic, &rig.block, - std.testing.io, null, mem_mod.RAM_SIZE_DEFAULT, + std.testing.allocator, + &rig.halt, + &rig.uart, + &rig.clint, + &rig.plic, + &rig.block, + std.testing.io, + null, + mem_mod.RAM_SIZE_DEFAULT, ); rig.cpu = Cpu.init(&rig.mem, mem_mod.RAM_BASE); return rig; @@ -679,7 +698,15 @@ test "integration: CLINT → M MTI ISR → mip.SSIP → S SSI ISR end-to-end" { var plic = plic_dev.Plic.init(); var block = @import("devices/block.zig").Block.init(); var mem = try Memory.init( - std.testing.allocator, &halt, &uart, &clint, &plic, &block, std.testing.io, null, mem_mod.RAM_SIZE_DEFAULT, + std.testing.allocator, + &halt, + &uart, + &clint, + &plic, + &block, + std.testing.io, + null, + mem_mod.RAM_SIZE_DEFAULT, ); defer mem.deinit(); @@ -765,13 +792,17 @@ test "integration: CLINT → M MTI ISR → mip.SSIP → S SSI ISR end-to-end" { // Step through reset, then drop into U, then let CLINT fire. var i: u32 = 0; while (i < 30) : (i += 1) { - cpu.step() catch |e| switch (e) { error.Halt, error.FatalTrap => break }; + cpu.step() catch |e| switch (e) { + error.Halt, error.FatalTrap => break, + }; } // Now advance the wall clock so MTIP fires at the next boundary. clint_dev.fixture_clock_ns = 20_000; // mtime = 200, mtimecmp = 100 → pending i = 0; while (i < 200) : (i += 1) { - cpu.step() catch |e| switch (e) { error.Halt, error.FatalTrap => break }; + cpu.step() catch |e| switch (e) { + error.Halt, error.FatalTrap => break, + }; const sentinel = try mem.loadWordPhysical(RAM_BASE + 0x800); if (sentinel == 1) break; } @@ -800,10 +831,10 @@ test "WFI returns promptly when a deliverable interrupt arrives during idle" { defer rig.deinit(); // Configure delegation + enable so SEIP delivers to S. - rig.cpu.privilege = .U; // U < S → trap deliverable regardless of sstatus.SIE + rig.cpu.privilege = .U; // U < S → trap deliverable regardless of sstatus.SIE rig.cpu.csr.stvec = 0x8000_0500; - rig.cpu.csr.mideleg = 1 << 9; // delegate SEIP to S - rig.cpu.csr.mie = 1 << 9; // SEIE + rig.cpu.csr.mideleg = 1 << 9; // delegate SEIP to S + rig.cpu.csr.mie = 1 << 9; // SEIE // PLIC: src 1 priority 1, enabled, threshold 0. try rig.cpu.memory.plic.writeByte(0x0004, 1); try rig.cpu.memory.plic.writeByte(0x2080, 0x02); diff --git a/src/emulator/devices/uart.zig b/src/emulator/devices/uart.zig index 5ae19a6..56a9187 100644 --- a/src/emulator/devices/uart.zig +++ b/src/emulator/devices/uart.zig @@ -132,6 +132,23 @@ pub const RxPump = struct { } for (buf[0..n]) |b| _ = uart.pushRx(b); } + + /// Drain at most one byte. Used by idleSpin so a busy shell observes + /// each `--input` byte as a separate keystroke, with the cooked-mode + /// console echo interleaved with shell prompts. Bulk drains would + /// emit every echo at once, before the shell can print successive + /// prompts between commands. + pub fn drainOne(self: *RxPump, io: std.Io, uart: *Uart) void { + if (self.eof) return; + if (uart.rx_count >= RX_CAPACITY) return; + var b: [1]u8 = undefined; + const n = self.file.readStreaming(io, &.{&b}) catch 0; + if (n == 0) { + self.eof = true; + return; + } + _ = uart.pushRx(b[0]); + } }; test "writing to THR sends byte to writer" { diff --git a/src/kernel/sched.zig b/src/kernel/sched.zig index 1fbd976..e6b6882 100644 --- a/src/kernel/sched.zig +++ b/src/kernel/sched.zig @@ -75,21 +75,30 @@ pub fn scheduler() noreturn { while (true) asm volatile ("wfi"); } // Nothing runnable but something is alive (embryo / sleeping / - // zombie). Open a one-instruction SIE window so a pending PLIC - // block IRQ (or timer SSI) can be delivered. While the window - // is open, swap stvec to s_kernel_trap_entry — the user vector - // would clobber the sleeping proc's tf and kstack. The kernel - // vector saves caller-saved regs on the current (scheduler) - // stack, runs s_kernel_trap_dispatch, restores, and srets back - // here. SPIE is forced to 0 by the dispatcher so the trailing - // csrc closes the window without re-trapping on a still-pending - // timer SSI. + // zombie). Open a WFI window so a pending PLIC block IRQ (or + // timer SSI) can be delivered. While the window is open, swap + // stvec to s_kernel_trap_entry — the user vector would clobber + // the sleeping proc's tf and kstack. The kernel vector saves + // caller-saved regs on the current (scheduler) stack, runs + // s_kernel_trap_dispatch, restores, and srets back here. SPIE + // is forced to 0 by the dispatcher so the trailing csrc closes + // the window without re-trapping on a still-pending timer SSI. + // s_kernel_trap_dispatch also advances sepc by 4 so sret returns + // to the post-wfi instruction (csrc) rather than re-executing + // wfi in a fixed-point loop. + // + // WFI (instead of a bare csrs/csrc pair) is required so that the + // emulator's UART RX pump runs (cpu.idleSpin fires on WFI). + // Without WFI the pump never drains --input bytes into the RX + // FIFO, so PLIC source 10 never asserts and the shell never + // receives its input. const kvec: u32 = @intCast(@intFromPtr(&s_kernel_trap_entry)); const uvec: u32 = @intCast(@intFromPtr(&s_trap_entry)); const SSTATUS_SIE: u32 = 1 << 1; asm volatile ( \\ csrw stvec, %[k] \\ csrs sstatus, %[b] + \\ wfi \\ csrc sstatus, %[b] \\ csrw stvec, %[u] : diff --git a/src/kernel/trap.zig b/src/kernel/trap.zig index e8b24c5..14c23f6 100644 --- a/src/kernel/trap.zig +++ b/src/kernel/trap.zig @@ -143,6 +143,24 @@ export fn s_kernel_trap_dispatch() callconv(.c) void { : [m] "r" (@as(u32, 1 << 5)), : .{ .memory = true }); + // Advance sepc past the trapping instruction. The only call site of + // s_kernel_trap_dispatch is sched.scheduler's WFI window. The trap + // boundary's sepc points at the WFI (or, in the no-WFI fallback the + // SIE-window pattern previously used, the trailing csrc — also 4-byte). + // Without this, sret would return to WFI again and cpu.step's prologue + // would re-fire the same trap before the wfi instruction is fetched, + // producing a fixed-point loop in which the scheduler can never + // re-scan ptable. Skipping past the WFI lands us on csrc, which then + // closes the SIE window (or, if csrc was already the trap site, on + // the trailing csrw — still safe because SPIE was just cleared). + const sepc_val = asm volatile ("csrr %[v], sepc" + : [v] "=r" (-> u32), + ); + asm volatile ("csrw sepc, %[v]" + : + : [v] "r" (sepc_val +% 4), + : .{ .memory = true }); + if (is_interrupt and cause == 1) { clearSipSsip(); return; From f09f8a0c1af86c0cc6676978b9ee2d46c980900a Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 10:59:08 +0800 Subject: [PATCH 44/47] =?UTF-8?q?feat(e2e):=20add=20e2e-shell=20=E2=80=94?= =?UTF-8?q?=20scripted=20ls/echo/cat/rm/exit=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 3.E milestone (Task 34): boot kernel-fs.elf against shell-fs.img with --input piping the canonical session "ls /bin / echo hi > /tmp/x / cat /tmp/x / rm /tmp/x / exit" through the UART RX path. Asserts prompt+command echo, ls visibility of the binaries we shipped in /bin, the cat round-trip ("hi"), and a clean halt. The shell harness mirrors tests/e2e/fs.zig's spawn + collect + landmark shape; build.zig wires e2e-shell after e2e-fs and depends on the shell-fs-img + ccc + kernel-fs.elf install steps. Co-Authored-By: Claude Opus 4.7 (1M context) --- build.zig | 18 ++++++ tests/e2e/shell.zig | 115 ++++++++++++++++++++++++++++++++++++++ tests/e2e/shell_input.txt | 5 ++ 3 files changed, 138 insertions(+) create mode 100644 tests/e2e/shell.zig create mode 100644 tests/e2e/shell_input.txt diff --git a/build.zig b/build.zig index fa72e7e..866b35b 100644 --- a/build.zig +++ b/build.zig @@ -819,6 +819,24 @@ pub fn build(b: *std.Build) void { const e2e_fs_step = b.step("e2e-fs", "Run the Phase 3.D fs-read e2e test (init opens /etc/motd)"); e2e_fs_step.dependOn(&e2e_fs_run.step); + const shell_e2e_exe = b.addExecutable(.{ + .name = "e2e-shell", + .root_module = b.createModule(.{ + .root_source_file = b.path("tests/e2e/shell.zig"), + .target = b.graph.host, + .optimize = .Debug, + }), + }); + const shell_e2e_run = b.addRunArtifact(shell_e2e_exe); + shell_e2e_run.step.dependOn(b.getInstallStep()); + shell_e2e_run.step.dependOn(shell_fs_img_step); + shell_e2e_run.addFileArg(exe.getEmittedBin()); + shell_e2e_run.addFileArg(shell_fs_img); + shell_e2e_run.addFileArg(kernel_fs_elf.getEmittedBin()); + shell_e2e_run.addFileArg(b.path("tests/e2e/shell_input.txt")); + const e2e_shell_step = b.step("e2e-shell", "Run the Phase 3.E shell e2e test"); + e2e_shell_step.dependOn(&shell_e2e_run.step); + // qemu-diff-kernel: debug-only trace diff against QEMU. Requires // qemu-system-riscv32 on PATH; not run by CI. const qemu_diff_kernel_cmd = b.addSystemCommand(&.{ diff --git a/tests/e2e/shell.zig b/tests/e2e/shell.zig new file mode 100644 index 0000000..3cfee4d --- /dev/null +++ b/tests/e2e/shell.zig @@ -0,0 +1,115 @@ +// tests/e2e/shell.zig — Phase 3.E milestone verifier. +// +// Spawns ccc --input --disk shell-fs.img kernel-fs.elf, +// captures stdout, asserts: +// - exit code 0 +// - stdout contains each of the scripted session landmarks: +// "$ ls /bin" (prompt + command echo) +// "sh\n" (ls output — sh binary present in /bin) +// "$ echo hi > /tmp/x" +// "$ cat /tmp/x" +// "hi\n" (cat output) +// "$ rm /tmp/x" +// "$ exit" + +const std = @import("std"); +const Io = std.Io; + +const FAIL_EXIT: u8 = 1; +const USAGE_EXIT: u8 = 2; + +pub fn main(init: std.process.Init) !u8 { + const io = init.io; + const gpa = init.gpa; + + var stderr_buf: [512]u8 = undefined; + var stderr_fw: Io.File.Writer = .init(.stderr(), io, &stderr_buf); + const stderr = &stderr_fw.interface; + + const argv = try init.minimal.args.toSlice(gpa); + defer gpa.free(argv); + + if (argv.len != 5) { + stderr.print( + "usage: {s} \n", + .{argv[0]}, + ) catch {}; + stderr.flush() catch {}; + return USAGE_EXIT; + } + + const child_argv = &[_][]const u8{ + argv[1], + "--input", + argv[4], + "--disk", + argv[2], + argv[3], + }; + var child = try std.process.spawn(io, .{ + .argv = child_argv, + .stdin = .ignore, + .stdout = .pipe, + .stderr = .inherit, + }); + + const MAX_BYTES: usize = 65536; + var read_buf: [4096]u8 = undefined; + var reader = child.stdout.?.reader(io, &read_buf); + const out = reader.interface.allocRemaining(gpa, .limited(MAX_BYTES)) catch |err| switch (err) { + error.StreamTooLong => { + stderr.print("shell_verify_e2e: output exceeded {d} bytes\n", .{MAX_BYTES}) catch {}; + stderr.flush() catch {}; + child.kill(io); + return FAIL_EXIT; + }, + else => return err, + }; + defer gpa.free(out); + + const term = try child.wait(io); + switch (term) { + .exited => |code| if (code != 0) { + stderr.print( + "shell_verify_e2e: expected exit 0, got {d}\nstdout was:\n{s}\n", + .{ code, out }, + ) catch {}; + stderr.flush() catch {}; + return FAIL_EXIT; + }, + else => { + stderr.print( + "shell_verify_e2e: child terminated abnormally: {any}\nstdout was:\n{s}\n", + .{ term, out }, + ) catch {}; + stderr.flush() catch {}; + return FAIL_EXIT; + }, + } + + const landmarks = [_][]const u8{ + "$ ls /bin", + "sh\n", + "$ echo hi > /tmp/x", + "$ cat /tmp/x", + "hi\n", + "$ rm /tmp/x", + "$ exit", + }; + + var all_ok = true; + for (landmarks) |lm| { + if (std.mem.indexOf(u8, out, lm) == null) { + stderr.print("shell_verify_e2e: missing landmark {s}\n", .{lm}) catch {}; + all_ok = false; + } + } + + if (!all_ok) { + stderr.print("shell_verify_e2e: stdout was:\n{s}\n", .{out}) catch {}; + stderr.flush() catch {}; + return FAIL_EXIT; + } + + return 0; +} diff --git a/tests/e2e/shell_input.txt b/tests/e2e/shell_input.txt new file mode 100644 index 0000000..d3a202e --- /dev/null +++ b/tests/e2e/shell_input.txt @@ -0,0 +1,5 @@ +ls /bin +echo hi > /tmp/x +cat /tmp/x +rm /tmp/x +exit From c826813d75e283dfd5c032a5e9618695426d4850 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 11:02:14 +0800 Subject: [PATCH 45/47] fix(kmain): wire console fds 0/1/2 in the Phase 2 PID 1 + PID 2 branch After 0f84951 routed sysWrite through file.write, every user proc needs ofile[0..2] pointing at a Console-typed file or write(1, ...) silently fails with -1. The FS_DEMO and FORK_DEMO branches already do this; the plain Phase 2 branch (kernel.elf single-proc + kernel-multi.elf two-proc) did not, so e2e-kernel and e2e-multiproc-stub regressed to "ticks observed: N" with no user output. Initialize the file table once, allocate one Console entry, dup it onto each proc's fd 0/1/2. PID 2 gets its own three dups so file.close ref-counting stays accurate. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/kernel/kmain.zig | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/kernel/kmain.zig b/src/kernel/kmain.zig index 71a2c10..f09b011 100644 --- a/src/kernel/kmain.zig +++ b/src/kernel/kmain.zig @@ -249,6 +249,23 @@ export fn kmain() callconv(.c) noreturn { pid1.sz = vm.USER_TEXT_VA + 0x10000; // initial brk above text region pid1.state = .Runnable; + // Phase 3.E: sysWrite routes through file.write, so each user proc's + // ofile[0..2] must point at a Console-typed file entry. Without this + // wiring, write(1, ...) returns -1 and the user payload's "hello from + // u-mode" output disappears. Install one shared console entry; PID 1 + // gets fds 0/1/2 dup'd onto it, and (if MULTI_PROC) PID 2 gets the + // same. + file.init(); + const console_fidx = file.alloc() orelse kprintf.panic("kmain: file.alloc console", .{}); + file.ftable[console_fidx].type = .Console; + file.ftable[console_fidx].ip = null; + file.ftable[console_fidx].off = 0; + _ = file.dup(console_fidx); + _ = file.dup(console_fidx); + pid1.ofile[0] = console_fidx; + pid1.ofile[1] = console_fidx; + pid1.ofile[2] = console_fidx; + // Optional: PID 2. if (boot_config.MULTI_PROC) { const pid2 = proc.alloc() orelse kprintf.panic("kmain: alloc PID 2", .{}); @@ -265,6 +282,13 @@ export fn kmain() callconv(.c) noreturn { pid2.tf.sp = vm.USER_STACK_TOP; pid2.sz = vm.USER_TEXT_VA + 0x10000; pid2.state = .Runnable; + + _ = file.dup(console_fidx); + _ = file.dup(console_fidx); + _ = file.dup(console_fidx); + pid2.ofile[0] = console_fidx; + pid2.ofile[1] = console_fidx; + pid2.ofile[2] = console_fidx; } // Install the S-mode trap vector + sscratch (will be overwritten on From 804f0d348bada8a868f9df5ab2e8802a0e8f192d Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 11:09:24 +0800 Subject: [PATCH 46/47] =?UTF-8?q?docs:=20README=20=E2=80=94=20add=20Phase?= =?UTF-8?q?=203.E=20status,=20layout,=20build=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status block: bump headline to Plan 3.E done; append a 3.E paragraph covering FS write path (writei + bmap lazy alloc + iupdate + ialloc + itrunc + dirlink/dirunlink + fsops glue), console as fd 0/1/2 with cooked-mode line discipline, UART RX through PLIC IRQ #10, the WFI + sepc-advance + paced rx_pump trio that lets --input bytes interleave with shell prompts, the new mkdirat / unlinkat syscalls + openat extensions, the userland stdlib at src/kernel/user/lib/, and the seven new userland binaries; close with the canonical e2e-shell session transcript and Next: Plan 3.F. Layout: add console.zig, fs/fsops.zig, user/init_shell.zig + sh.zig + ls.zig + cat.zig + echo.zig + mkdir.zig + rm.zig, user/lib/ with the four stdlib pieces, userland/shell-fs/ tree, and tests/e2e/shell.zig + shell_input.txt. Building: add the seven kernel-* user-binary targets, shell-fs-img, and e2e-shell. Final regression sweep (all green): test, e2e-shell, e2e-fs, e2e-kernel, e2e-multiproc-stub, e2e-fork, e2e-plic-block, e2e-snake, e2e-hello-elf, e2e, e2e-mul, e2e-trap, riscv-tests, wasm. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ce1cc44..51715c6 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,21 @@ and `build.zig.zon` pins the minimum Zig version (0.16.0). | `zig build kernel-fork` | Build the Phase 3.C `kernel-fork.elf` (same kernel objects + embedded `init.elf` + `hello.elf`) | | `zig build kernel-fs` | Build the Phase 3.D `kernel-fs.elf` (FS-mode kernel; loads `/bin/init` from disk) | | `zig build kernel-fs-init` | Build `fs_init.elf` (the on-disk `/bin/init` payload baked into `fs.img`) | +| `zig build kernel-init-shell` | Build the Phase 3.E `init_shell.elf` (on-disk `/bin/init` for `shell-fs.img`; loops fork-exec-sh-wait) | +| `zig build kernel-sh` | Build the Phase 3.E `sh.elf` (line/token/redirect/builtins/fork+exec) | +| `zig build kernel-ls` | Build the Phase 3.E `ls.elf` | +| `zig build kernel-cat` | Build the Phase 3.E `cat.elf` | +| `zig build kernel-echo` | Build the Phase 3.E `echo.elf` | +| `zig build kernel-mkdir` | Build the Phase 3.E `mkdir.elf` | +| `zig build kernel-rm` | Build the Phase 3.E `rm.elf` | | `zig build mkfs` | Build the host-side `mkfs` tool (lays out a 4 MB image: superblock + bitmap + inode table + data blocks) | | `zig build fs-img` | Stage `userland/fs/` + `fs_init.elf` and run `mkfs` to produce `zig-out/fs.img` | +| `zig build shell-fs-img` | Stage `userland/shell-fs/` + every Phase 3.E userland binary and run `mkfs` to produce `zig-out/shell-fs.img` (init_shell at `/bin/init`) | | `zig build e2e-kernel` | Run `ccc kernel.elf` and assert stdout matches `hello from u-mode\nticks observed: N\n` with N > 0 (Phase 2 §Definition of done) | | `zig build e2e-multiproc-stub` | Run `ccc kernel-multi.elf` and assert stdout contains both `hello from u-mode\n` and `[2] hello from u-mode\n`, plus a `ticks observed: N\n` trailer (Plan 3.B milestone) | | `zig build e2e-fork` | Boot `kernel-fork.elf`; `init` forks `/bin/hello`; parent reaps; emulator returns 0 (Plan 3.C milestone) | | `zig build e2e-fs` | Boot `kernel-fs.elf` against `fs.img`; on-disk `/bin/init` opens `/etc/motd`, reads it, writes to fd 1, exits 0 (Plan 3.D milestone) | +| `zig build e2e-shell` | Boot `kernel-fs.elf` against `shell-fs.img` with `--input tests/e2e/shell_input.txt`; assert prompt+command echo for the canonical `ls /bin / echo / cat / rm / exit` session and a clean halt (Plan 3.E milestone) | | `zig build qemu-diff-kernel` | Diff the kernel.elf trace against `qemu-system-riscv32` (debug aid; needs QEMU installed) | | `zig build plic-block-test` | Build the Phase 3.A integration test ELF (asm-only S-mode program) | | `zig build e2e-plic-block` | Build a 4 MB test image, run `ccc --disk … plic_block_test.elf`, assert exit 0 (Plan 3.A milestone: full CMD → IRQ → trap → claim path) | @@ -128,11 +137,11 @@ to "GitHub Actions" in repo settings (one-time manual step). ## Status -**Phase 3 Plan D done — bufcache + block driver + FS read path.** Plan 3.A -merged: PLIC, simple block device, UART RX, `--disk` and `--input` flags, -real `wfi` idle. Plan 3.B merged: free-list page allocator, `ptable[NPROC=16]`, -round-robin scheduler with `swtch`, kernel-side ELF32 loader, -`getpid`/`sbrk`/`yield` syscalls, second embedded user ELF, +**Phase 3 Plan E done — FS write path + console fd + shell + utilities.** +Plan 3.A merged: PLIC, simple block device, UART RX, `--disk` and `--input` +flags, real `wfi` idle. Plan 3.B merged: free-list page allocator, +`ptable[NPROC=16]`, round-robin scheduler with `swtch`, kernel-side ELF32 +loader, `getpid`/`sbrk`/`yield` syscalls, second embedded user ELF, `e2e-multiproc-stub` running PID 1 + PID 2. Plan 3.C merged: `fork` (full address-space copy), `execve` (in-place AS rebuild + System-V argv tail), `wait4` (sleep on self until zombie child), `exit` (reparent + zombie + wake @@ -148,7 +157,21 @@ directory tree. `proc.exec` now resolves the path via `namei` + `readi` into a kernel scratch buffer (FS-mode), or via the embedded-blob lookup (single / multi / fork modes) — selected at compile time per kernel variant. `e2e-fs` runs `kernel-fs.elf` against `fs.img`: the on-disk `/bin/init` opens -`/etc/motd`, reads it, writes the contents to fd 1, exits 0. +`/etc/motd`, reads it, writes the contents to fd 1, exits 0. Plan 3.E +merged: FS write path (`writei` with `bmap` lazy alloc, `iupdate`, `ialloc`, +`itrunc`, `iput`-on-zero truncate, real `dirlink` + `dirunlink`, +`fs/fsops.zig` glue), console as fd 0/1/2 with cooked-mode line discipline +(echo, backspace, `^U`, `^C`, `^D`, line completion), UART RX delivered +through PLIC IRQ #10 → `uart.isr` → `console.feedByte`, scheduler now +`wfi`s in its idle window so `cpu.idleSpin` paces `--input` byte delivery +to match interactive cadence. New syscalls: `mkdirat` (#34), `unlinkat` +(#35); `openat` extended with `O_CREAT`/`O_TRUNC`/`O_APPEND`; `write` now +routes any fd through `file.write`. User stdlib lands at +`src/kernel/user/lib/` (`start.S`, `usys.S`, `ulib.zig`, `uprintf.zig`), +fed by an `addUserBinary` build helper. Userland: `init` (init_shell: +fork-exec-sh-wait), `sh` (line/token/redirect/builtins/fork+exec), `ls`, +`cat`, `echo`, `mkdir`, `rm`. `e2e-shell` runs the canonical scripted +session against `kernel-fs.elf` + `shell-fs.img`. **Phase 1 — RISC-V CPU emulator — complete.** @@ -257,7 +280,53 @@ space. The on-disk `init` reads `/etc/motd` and writes it to UART: hello from phase 3 ticks observed: 4 -Next: Plan 3.E — file write path + console line discipline + shell. +Plan 3.E (FS write path + console fd + shell + utilities) is merged. The +filesystem grew a write path: `inode.writei` with `bmap`'s lazy allocation +(`for_write` flag), `iupdate` (in-memory → on-disk inode flush), `ialloc` +(scan inode table + initial `iupdate`), `itrunc` (free direct + indirect +blocks; called from `iput` when ref+nlink hit zero), `dirlink` (real impl +with empty-slot scan), `dirunlink`, plus an `fs/fsops.zig` create/unlink +glue. `openat` gained `O_CREAT` / `O_TRUNC` / `O_APPEND`; new syscalls +`mkdirat` (#34) + `unlinkat` (#35). `console.zig` lands as the fd 0/1/2 +backing — cooked-mode line discipline (per-byte echo, backspace, `^U` +line-kill, `^C` foreground-proc kill via `proc.kill`, `^D` EOF, `\n` line +commit + sleeper wakeup), Raw mode arm wired but only exercised by 3.F's +editor. UART RX is now alive: PLIC source 10 → `uart.isr` reads RBR until +empty, feeding each byte to `console.feedByte`. The scheduler's idle path +now executes `wfi` (so `cpu.idleSpin` runs and the emulator's `rx_pump` +paces `--input` bytes one-per-iteration to interleave with cooked-mode +echo); `s_kernel_trap_dispatch` advances `sepc` past the `wfi` so the +SIE window can actually close. A small user stdlib lands at +`src/kernel/user/lib/` (RV32 `_start` parsing argc/argv, 19 `ecall` stubs, +`mem*`/`str*` + `O_*` constants, a minimal `printf`). The +`addUserBinary` build helper packs 7 new userland binaries — `init_shell` +(loops fork-exec-sh-wait, exits cleanly when sh exits 0), `sh` +(line/token/redirect/builtins/fork+exec), `ls`, `cat`, `echo`, `mkdir`, +`rm`. `mkfs.zig` learned `--init` (override `/bin/init`) and walks every +top-level subdir of `--root` (so `/tmp/` empty-dir staging carries +through). `shell-fs.img` is the parallel image that bakes init_shell as +`/bin/init` and ships every utility under `/bin/`. The Phase 3.E milestone +runs the scripted session through `--input`: + + $ zig build kernel-fs shell-fs-img && zig build run -- --input tests/e2e/shell_input.txt --disk zig-out/shell-fs.img zig-out/bin/kernel-fs.elf + $ ls /bin + . + .. + cat + init + echo + sh + mkdir + ls + rm + $ echo hi > /tmp/x + $ cat /tmp/x + hi + $ rm /tmp/x + $ exit + ticks observed: 6 + +Next: Plan 3.F — `edit` userland + raw-mode editor + `e2e-persist`. ## Layout @@ -297,15 +366,17 @@ src/ uart.zig # kernel-side UART driver plic.zig # kernel-side PLIC driver (setPriority/enable/setThreshold/claim/complete) block.zig # kernel-side block driver (single-outstanding submit + sleep on req; isr wakes) - file.zig # NFILE=64 file table + read/lseek/fstat (single Inode type in 3.D; Console in 3.E) + file.zig # NFILE=64 file table + read/write/lseek/fstat — 3.E adds Console-typed entries for fd 0/1/2 + console.zig # 3.E: cooked-mode line discipline (echo + backspace + ^C/^U/^D + \n commit) + Raw arm; backs fd 0/1/2 fs/ layout.zig # on-disk constants (BLOCK_SIZE, NBLOCKS, NINODES, SuperBlock, DiskInode, DirEntry) bufcache.zig # NBUF=16 LRU buffer cache with sleep-on-busy + bget/brelse/bread/bwrite - balloc.zig # block bitmap (alloc/free; write-side reserved for 3.E) - inode.zig # NINODE=32 in-memory inode cache + iget/iput/ilock/iunlock + bmap + readi - dir.zig # DirEntry record + dirlookup + dirlink stub (3.E) + balloc.zig # block bitmap (alloc/free; write-side wired in 3.E) + inode.zig # NINODE=32 in-memory inode cache + iget/iput/ilock/iunlock + bmap (lazy alloc on for_write) + readi/writei + iupdate + ialloc + itrunc + dir.zig # DirEntry record + dirlookup + dirlink + dirunlink (3.E) path.zig # namei + nameiparent (root for absolute, cur.cwd for relative) - mkfs.zig # host-side tool: walks --root + --bin into a 4 MB image (super + bitmap + inodes + data) + fsops.zig # 3.E: create + unlink glue used by sysOpenat (O_CREAT) / sysMkdirat / sysUnlinkat + mkfs.zig # host-side tool: walks --root subdirs + --bin into a 4 MB image; --init overrides /bin/init linker.ld # kernel.elf load layout user/ userprog.zig # PID 1 user payload (embedded into kernel.elf) @@ -313,11 +384,28 @@ src/ init.zig # init userland for kernel-fork.elf (fork+exec+wait) hello.zig # hello userland for kernel-fork.elf (write+exit) fs_init.zig # on-disk /bin/init for kernel-fs.elf (open /etc/motd, read, write fd 1, exit) + init_shell.zig # 3.E: on-disk /bin/init for shell-fs.img (loops fork-exec-sh-wait; exits cleanly on sh status 0) + sh.zig # 3.E: shell — line read, token split, redirect (< > >>), builtins (cd / pwd / exit), fork+exec + ls.zig # 3.E: directory listing + Stat dispatch + cat.zig # 3.E: read fd or args, write fd 1 + echo.zig # 3.E: print joined args + \n + mkdir.zig # 3.E: mkdirat for each arg + rm.zig # 3.E: unlinkat for each arg + lib/ + start.S # 3.E: RV32 _start — parses argc/argv from sp tail, calls main, ecall exit + usys.S # 3.E: 19 syscall stubs (li a7; ecall; ret) + ulib.zig # 3.E: mem*/str* + syscall externs + Stat / O_* constants + uprintf.zig # 3.E: minimal printf(fd, fmt, args) user_linker.ld # user-side linker script userland/ fs/ etc/ motd # staged content for fs.img: "hello from phase 3\n" + shell-fs/ # 3.E: staging tree for shell-fs.img (init_shell + utilities go to /bin via mkfs) + etc/ + motd # same 19-byte content as userland/fs/etc/motd + tmp/ + .gitkeep # carrier for empty /tmp/ in git; mkfs skips dot-files demo/ web_main.zig # freestanding wasm entry — runStart/runStep/setMtimeNs/pushInput/consumeOutput, fixed 2 MB ELF buffer (programs fetched at runtime, not embedded) programs/ @@ -332,7 +420,10 @@ tests/ multiproc.zig # Plan 3.B verifier (PID 1 + PID 2 interleaving) fork.zig # Plan 3.C verifier (fork/exec/wait/exit) fs.zig # Plan 3.D verifier (init opens /etc/motd, writes contents to fd 1) + shell.zig # Plan 3.E verifier (scripted ls/echo/cat/rm/exit session) + shell_input.txt # 51-byte canonical session piped via --input snake.zig # snake e2e verifier (deterministic input → GAME OVER) + snake_input.txt # snake e2e input fixture fixtures/ # tiny hand-crafted ELF used only by elf.zig tests riscv-tests/ # upstream submodule: riscv-software-src/riscv-tests riscv-tests-shim/ # weak handlers + riscv_test.h overrides for the shared test env From 47642f7a5710856d6640236c5a31e3d31c8cc3fc Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 11:25:29 +0800 Subject: [PATCH 47/47] =?UTF-8?q?docs(deck):=20add=20Phase=203.E=20?= =?UTF-8?q?=E2=80=94=20write=20side=20+=20console=20+=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new chapter slides between the 3.D init-from-disk slide and the epilogue: - Ch 3.E · FS write path: writei + bmap.for_write lazy alloc, iupdate / ialloc / itrunc / iput-on-zero, real dirlink + dirunlink, the fsops.create/unlink glue, mkdirat/unlinkat + openat O_CREAT/O_TRUNC/ O_APPEND, and the sysWrite reroute through file.write that surfaced the kernel-multi/kernel-fs Console-fd regression. - Ch 3.E · console + WFI: console.zig as fd 0/1/2 backing with cooked-mode line discipline (echo + ^C/^U/^D + \n commit), UART RX via PLIC #10, and the WFI fixed-point loop that adding wfi to the scheduler idle window created — broken by sepc+=4 in s_kernel_trap_dispatch + paced one-byte-at-a-time idleSpin drain. - Ch 3.E · shell + milestone: the userland stdlib at user/lib/, the seven binaries (init_shell, sh, ls, cat, echo, mkdir, rm), the sh main loop, and the canonical e2e-shell session transcript. TOC + intro caption: bump the Phase 3 progress line to "3.a + 3.b + 3.c + 3.d + 3.e done" with the 3.E summary; add 3.E to the deck-walks list. Epilogue: extend the Phase 3 paragraph with the 3.E recap, add a 3.E checklist row, and replace "Next · plan 3.e" with "Next · plan 3.f" describing the editor + e2e-persist work. Co-Authored-By: Claude Opus 4.7 (1M context) --- index.html | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index 95b34ec..e111d56 100644 --- a/index.html +++ b/index.html @@ -345,8 +345,8 @@

Five phases, one browser

3
-
Multi-process OS + shell in progress · 3.a + 3.b + 3.c + 3.d done
-
Process scheduler · fork/exec · block device · filesystem · tiny shell, ls/cat, an editor. Plans 3.A + 3.B + 3.C + 3.D landed: emulator PLIC + block + UART RX, the kernel-side ptable / scheduler / swtch / ELF loader, the full Unix-shaped fork / exec / wait / exit / kill-flag lifecycle, and a real read-side filesystem — bufcache + inode cache + namei, with /bin/init loaded from disk via openat/read.
+
Multi-process OS + shell in progress · 3.a + 3.b + 3.c + 3.d + 3.e done
+
Process scheduler · fork/exec · block device · filesystem · tiny shell, ls/cat, an editor. Plans 3.A + 3.B + 3.C + 3.D + 3.E landed: emulator PLIC + block + UART RX, the kernel-side ptable / scheduler / swtch / ELF loader, the full Unix-shaped fork / exec / wait / exit / kill-flag lifecycle, the read-side filesystem (bufcache + inode cache + namei · /bin/init from disk), and finally the write side + console + shellwritei / ialloc / itrunc / dirlink, cooked-mode line discipline on fd 0/1/2, and a real sh driving ls / cat / echo / mkdir / rm.
@@ -371,7 +371,7 @@

Five phases, one browser

-
This deck walks Phase 1 and Phase 2 in full, plus Plans 3.A, 3.B, 3.C, and 3.D — the emulator-side substrate, the kernel-side multi-process foundation, the Unix-shaped process lifecycle, and the read-side filesystem that finally loads /bin/init from disk.
+
This deck walks Phase 1 and Phase 2 in full, plus Plans 3.A, 3.B, 3.C, 3.D, and 3.E — the emulator-side substrate, the kernel-side multi-process foundation, the Unix-shaped process lifecycle, the read-side filesystem that finally loads /bin/init from disk, and the write-side + console + shell that gives the OS its first interactive prompt.
@@ -1441,11 +1441,139 @@

/bin/init opens /etc/motd, e2e-fs: ccc --disk fs.img kernel-fs.elf → stdout contains hello from phase 3\n (motd content) + the canonical PID-1 ticks observed: N\n trailer; exit 0. Getting here surfaced nine subtle bugs along the way — most memorably, file.read's 4 KB stack-allocated kbuf overflowing the per-process kernel stack into the adjacent scheduler stack page (var x = undefined's 0xaa fill made the corruption pattern unmissable). Hoisted to a .bss static; problem solved. - + +
+
chapter 3.e · the write side
+

From read-only to mutable: writei + ialloc + itrunc + a real dirlink

+

Plan 3.D shipped the read side; 3.E completes the FS by adding the write side. inode.writei mirrors readi but flips a for_write flag through bmap so missing direct/indirect blocks get lazily allocated via balloc. iupdate flushes the in-memory dinode back to its inode-table block. ialloc claims the first free inode slot. itrunc frees direct + indirect blocks, called from iput when ref+nlink hit zero. dirlink stops being a stub — it scans for an empty slot or appends — and a new dirunlink zeros the directory entry. A new fs/fsops.zig wraps the create + unlink pipelines so the syscall layer stays thin.

+
+
// fs/inode.zig — bmap with for_write hint
+fn bmap(ip: *InMemInode, bn: u32,
+        for_write: bool) ?u32 {
+    if (bn < NDIRECT) {
+        var a = ip.dinode.addrs[bn];
+        if (a == 0 and for_write) {
+            a = balloc.alloc() orelse return null;
+            ip.dinode.addrs[bn] = a;
+            iupdate(ip); // flush
+        }
+        return if (a == 0) null else a;
+    }
+    // indirect: same shape, one extra bread
+    ...
+}
+
+// fsops.zig — the create pipeline
+pub fn create(path: []const u8,
+                 t: InodeType) ?*InMemInode {
+    const parent = nameiparent(path) orelse ...;
+    const name = lastComponent(path);
+    if (dirlookup(parent, name)) |existing|
+        return existing; // idempotent
+    const ip = ialloc(t) orelse return null;
+    dirlink(parent, name, ip.inum);
+    return ip;
+}
+
+

New + extended syscalls

+

+ New: mkdirat (#34) · unlinkat (#35).
+ Extended: openat with O_CREAT / O_TRUNC / O_APPEND.
+ Refactored: sysWrite stops short-circuiting straight to UART — it routes any fd through file.write, which dispatches to console.write (for the new Console-typed entries on fd 0/1/2) or writei (for inode-backed fds).

+ The file.write reroute means every previous build that hadn't wired Console fds suddenly needs them — a regression caught by e2e-kernel + e2e-multiproc-stub going silent until kmain's Phase 2 branch grew the same 3-line console install the FS branch already had. +

+
+
+
iput-on-zero is the last piece: when a file's ref + nlink hit zero, iput calls itrunc, frees the inode, and writes a zero type back through iupdate. Combined with unlinkat's dirunlink + nlink--, an rm /tmp/x actually returns the data blocks + inode slot to the pool.
+
+ + +
+
chapter 3.e · the input side
+

Cooked-mode line discipline on fd 0/1/2 — and the WFI loop it surfaced

+

console.zig is the new fd 0/1/2 backing. UART RX (PLIC source #10) drives uart.isr, which drains the FIFO via console.feedByte: cooked mode echoes the byte, handles backspace / ^U line-kill / ^C-via-proc.kill / ^D EOF, and on \n commits the line + wakes any sleepers. Raw mode is wired (the console_set_mode syscall lands real this time) but only exercised by 3.F's editor. The trap dispatcher gains an IRQ_UART_RX = 10 arm that mirrors the block IRQ shape — claim → uart.isr → complete.

+
+
the input loop · why WFI matters
+
+ host stdin
+   ─► --input bytes (one keystroke at a time)
+       ─► cpu.idleSpin (only fires on WFI)
+           rx_pump.drainOne ─► uart.pushRx
+               PLIC src 10
+                 ─► s_trap_dispatch (cause 9)
+                     ─► uart.isr ─► console.feedByte
+                         echo ─► input.buf[]
+                         on \n: wakeup(&input.r)
+       sh resumes in console.read
+
+scheduler idle path  (3.D shape)
+   csrs SIE          // open SIE briefly
+   csrc SIE          // close it
+
+scheduler idle path  (3.E)
+   csrs SIE
+   wfi            // idleSpin runs
+   csrc SIE
+
+

The fixed-point loop

+

+ Adding wfi broke e2e-fs hard. cpu.step's prologue raises any pending S-trap before the next instruction is fetched. With SIE = 1, the pending PLIC IRQ traps before wfi is fetched; sret returns to wfi (sepc = wfi_addr); the same trap fires again. Forever.

+ Two fixes broke the loop: s_kernel_trap_dispatch advances sepc by 4 (the trap site is always the 4-byte WFI), and idleSpin drains one byte per iteration AFTER checking interrupts (so disk-I/O sleeps, where a block IRQ is already pending, don't leak --input bytes into the FIFO mid-boot — drain only happens when the guest is truly idle). +

+
+
+
The byte-at-a-time pacing matters: bulk-draining all 51 input bytes would emit every cooked-mode echo before sh can print its second-and-subsequent prompts. One byte per WFI iteration mirrors interactive-tty cadence, and the harness sees $ ls /bin\n$ echo hi > /tmp/x\n… in the right order.
+
+ + +
+
chapter 3.e · the milestone
+

An interactive prompt: init_shell forks sh, sh forks cat

+

A small userland stdlib lands at src/kernel/user/lib/: start.S parses argc/argv from the System-V tail and calls main; usys.S emits 19 syscall stubs (li a7; ecall; ret); ulib.zig bundles mem*/str* + Stat + O_* constants; uprintf.zig is a 90-line printf(fd, fmt, args). An addUserBinary build helper bolts these onto every userland binary identically. Seven binaries ship: init_shell (loops fork-exec-sh-wait, exits cleanly when sh status is 0), sh (line read + token split + redirect </>/>> + builtins cd/pwd/exit + fork+exec, ~250 LoC), and the four utilities ls / cat / echo / mkdir / rm.

+
+
// kernel/user/sh.zig — main loop
+export fn main(argc: u32, argv: [*]const [*:0]const u8) i32 {
+    while (true) {
+        const prompt: [2]u8 = .{ '$', ' ' };
+        _ = ulib.write(1, &prompt, 2);
+
+        const got = ulib.getline(0, &line_buf, LINE_MAX);
+        if (got <= 0) return 0;
+
+        const parsed = parseLine(&line_buf, got);
+        runCommand(&parsed); // fork+exec or builtin
+    }
+}
+
milestone session
+
+ $ ccc --input shell_input.txt \
+       --disk shell-fs.img kernel-fs.elf
+
+ $ ls /bin
+ .
+ ..
+ cat
+ init
+ echo
+ sh
+ mkdir
+ ls
+ rm
+ $ echo hi > /tmp/x
+ $ cat /tmp/x
+ hi
+ $ rm /tmp/x
+ $ exit
+ ticks observed: 6
+
+
shell-fs.img is the parallel image baking init_shell as /bin/init + every utility under /bin/. mkfs.zig learned --init (override) and walks every top-level subdir of --root (so the empty /tmp/ staging carries through). The e2e-shell harness pipes the canonical session through --input and asserts each prompt + command echo + the hi round-trip + the clean halt. This is our first interactive shell.
+
+ +
epilogue · where we are

Phase 1 · complete. Phase 2 · complete. Phase 3 · underway.

-

Phase 1 asked: can we run hello world end-to-end on an emulator we wrote? Yes. Phase 2 asked: can we host a real OS kernel? Also yes — three privilege levels, Sv32 paging, trap delegation, async timers, a one-process scheduler stub, and a user program that prints how many times the timer fired while it ran. Phase 3 is a multi-process OS with a filesystem and a shell — Plan 3.A landed the emulator-side hardware (PLIC, block device, UART RX); Plan 3.B landed the kernel-side multi-process foundation (free-list page_alloc, ptable[16], round-robin scheduler with swtch, kernel-side ELF loader); Plan 3.C landed the full Unix-shaped process lifecycle — fork, execve, wait4, exit, and a kill-flag — with init forking /bin/hello end-to-end on real ELFs; Plan 3.D just landed the read-side filesystem — bufcache, block driver with sleep-on-IRQ, inode cache + bmap + readi, namei, file table, seven new syscalls, and a host-side mkfs that builds a 4 MB image. /bin/init is no longer embedded — it loads from disk.

+

Phase 1 asked: can we run hello world end-to-end on an emulator we wrote? Yes. Phase 2 asked: can we host a real OS kernel? Also yes — three privilege levels, Sv32 paging, trap delegation, async timers, a one-process scheduler stub, and a user program that prints how many times the timer fired while it ran. Phase 3 is a multi-process OS with a filesystem and a shell — Plan 3.A landed the emulator-side hardware (PLIC, block device, UART RX); Plan 3.B landed the kernel-side multi-process foundation (free-list page_alloc, ptable[16], round-robin scheduler with swtch, kernel-side ELF loader); Plan 3.C landed the full Unix-shaped process lifecycle — fork, execve, wait4, exit, and a kill-flag — with init forking /bin/hello end-to-end on real ELFs; Plan 3.D landed the read-side filesystem — bufcache, block driver with sleep-on-IRQ, inode cache + bmap + readi, namei, file table, seven new syscalls, and a host-side mkfs that builds a 4 MB image, with /bin/init loaded from disk; Plan 3.E just landed the write side + console + shell — writei/ialloc/itrunc/dirlink, cooked-mode line discipline on fd 0/1/2 driven by UART RX through PLIC IRQ #10, a small userland stdlib, and seven user binaries including a real sh. The OS finally has an interactive prompt.

@@ -1496,12 +1624,16 @@

Phase 1 · complete. Phase 2
✓ 3.D · bufcache + block + FS read
PLIC + block kernel drivers · NBUF=16 LRU bufcache (sleep-on-busy) · NINODE=32 inode cache + bmap/readi · namei · NFILE=64 file table + per-proc ofile[16]/cwd · 7 new syscalls (openat/close/read/lseek/fstat/chdir/getcwd) · mkfs + 4 MB fs.img · separate kernel trap vector for the scheduler SIE window · /bin/init from disk · e2e-fs

+
+
✓ 3.E · FS write + console + shell
+
writei/bmap lazy alloc · iupdate/ialloc/itrunc · dirlink/dirunlink · fsops.create/unlink · mkdirat/unlinkat + openat O_CREAT/O_TRUNC/O_APPEND · console fd 0/1/2 cooked-mode line discipline · UART RX via PLIC #10 · scheduler WFI + sepc+4 + paced rx_pump · userland stdlib (start.S/usys.S/ulib/uprintf) · init_shell/sh/ls/cat/echo/mkdir/rm · shell-fs.img · e2e-shell
+
-

Next · plan 3.e

+

Next · plan 3.f

- Write side + console + shell.

- File write/mkdirat/unlinkat · console line discipline (^C, raw mode) · userland stdlib (start.S, usys.S, ulib, uprintf) · a tiny shell with ls/cat/echo. Milestone: a real interactive prompt. + Editor + persistence.

+ A cursor-moving edit binary that exercises raw-mode console (already wired in 3.E) with ANSI escapes, ^S save / ^X exit through the new write path. Plus e2e-persist — re-run against the same fs.img and observe writes survived.