Flash 361 lines
// fsh — the FlashOS shell. A line-at-a-time REPL over the unified fd
// ABI: read a line with flibc.readline (fd 0), tokenize it (one
// optional `|`), dispatch built-ins in-process, and fork + execvp
// external commands. Exactly one pipe stage is supported; richer
// parsing (redirection, multi-stage pipelines, quoting, globbing,
// `$VAR`, history) is the "fsh v2" bucket (future work).
//
// Entry is the flibc _start argc/argv shim (pulled in by the link
// directives below); `main` ignores argv. All buffers are function-local
// (stack) or string literals — rule 1: no allocator, no module-level
// mutable state. Module-level `var` would land in .bss, which the
// single R+X PT_LOAD (tools/fsh_linker.ld) cannot write; keeping the
// line / argv / scratch / fshrc buffers on the 64 KiB user stack both
// honours the no-heap rule and keeps the ELF a single segment.
//
// The pure tokenizer lives in tokenize.flash and is host-tested in
// isolation; this file is the SVC-driving shell loop, exercised end to
// end by the PID-1 hand-off: init execs /bin/fsh after the harness, and
// the boot watchdog treats the homescreen line fsh prints at REPL entry
// (the stable `type 'help' for commands` tail) as the boot success signal
// (reaching the prompt = pass).
use flibc
use "tokenize" as tok
use pwfile
use console_ui
use build_options
link "flibc_start"
link "flibc_mem"
const LINE_MAX usize = 256 // readline buffer (one input line)
const TOK_BUF usize = 256 // tokenizer scratch (NUL-joined argv bytes)
const FSHRC_MAX usize = 512 // /etc/fshrc slurp buffer
const PASSWD_MAX usize = 512 // /etc/passwd slurp buffer (whoami)
const CWD_MAX usize = 256 // getcwd buffer — matches the TaskStruct.cwd ABI ceiling (pwd)
// Command-history depth. The ring's slots live on the REPL's stack frame
// (rule 1 — no allocator / no .bss); 16 × HistSlot ≈ 4.2 KiB, comfortable on
// the 64 KiB user stack. Bumping this only costs stack.
const HIST_N usize = 16
// Unix-style privilege prompt: `# ` for root (euid 0), `$ ` for
// everyone else. Selected per REPL iteration via geteuid so a future
// in-shell privilege change is reflected immediately.
const PROMPT_ROOT = "# "
const PROMPT_USER = "$ "
// Homescreen banner, emitted once when fsh reaches its interactive REPL —
// rendered by console_ui.homescreen(), fed the project version from
// build.zig.zon via build_options, so no version literal lives here.
const AUTHOR = "ajhahnde"
const HELP_TEXT = "Commands:\n" ++ " cd [dir] change working directory\n" ++ " pwd print working directory\n" ++ " free show free page count\n" ++ " whoami print the logged-in user\n" ++ " reboot restart the machine\n" ++ " exit / logout end the session\n" ++ " help show this help\n" ++ "\n" ++ "Run a program: <cmd> [args] pipe: <a> | <b>\n" ++ "TAB completes commands + paths\n" ++ "\n"
// Built-in command names, offered alongside /bin for first-token TAB
// completion (these dispatch in-process, so they are not in /bin).
const BUILTINS = [_][]u8{ "cd", "pwd", "exit", "logout", "help", "free", "whoami", "reboot" }
export fn main(argc usize, argv argv) noreturn {
_ = argc
_ = argv
runFshrc()
repl()
flibc.exit()
}
// ---- I/O helpers (unified fd ABI) ----
fn emit(fd i32, s []u8) {
_ = flibc.sys.write_fd(fd, s.ptr, s.len)
}
// console_ui Sink bound to stdout (fd 1), so the shared renderers reach the
// shell's console.
fn consoleSink(bytes []u8) {
emit(1, bytes)
}
// ---- startup file ----
// Read /etc/fshrc once and run each non-comment, non-blank line through
// the same dispatcher the REPL uses. Silently skips when the file is
// absent (open < 0) — the rc file is optional. Kept free of `free` /
// meminfo so it adds no sys_dump_free checkpoint (the CI baseline count
// must stay deterministic).
fn runFshrc() {
fd := flibc.sys.open("/etc/fshrc")
if fd < 0 {
return
}
var buf [FSHRC_MAX]u8 = undefined
n := flibc.sys.read(fd, &buf, buf.len)
_ = flibc.sys.close(fd)
if n <= 0 {
return
}
content := buf[0..#intCast(n)]
var start usize = 0
var i usize = 0
while i <= content.len {
if i == content.len || content[i] == '\n' {
line := trim(content[start..i])
if line.len != 0 && line[0] != '#' {
dispatch(line)
}
start = i + 1
}
i += 1
}
}
fn trim(s []u8) []u8 {
var a usize = 0
var b usize = s.len
while a < b && isSpace(s[a]) {
a += 1
}
while b > a && isSpace(s[b - 1]) {
b -= 1
}
return s[a..b]
}
inline fn isSpace(c u8) bool {
return c == ' ' || c == '\t' || c == '\r' || c == '\n'
}
// ---- REPL ----
fn repl() {
var line_buf [LINE_MAX]u8 = undefined
// Caller-owned history ring (rule 1). Slots are written by readlineEdit
// before they are read back, so `undefined` backing is valid here.
var hist_slots [HIST_N]flibc.HistSlot = undefined
var hist = flibc.History.init(&hist_slots)
console_ui.homescreen(consoleSink, build_options.version, AUTHOR)
while true {
prompt := if (flibc.sys.geteuid() == 0) PROMPT_ROOT else PROMPT_USER
emit(1, prompt)
// Hand readline the live prompt so its double-TAB candidate listing can
// reprint `prompt` + line after the list. align(16): the >16-byte
// Completion is materialised on this frame and LLVM may SLP-store its
// adjacent slice fields with a `str q` (16-byte NEON) that faults on an
// 8-aligned slot under SCTLR_EL1.A — the strict-align vectorisation trap.
const comp flibc.Completion align(16) = .{ .builtins = &BUILTINS, .prompt = prompt }
switch flibc.readlineEdit(&line_buf, comp, &hist) {
.eof => return, // ^D on an empty line / stream closed → logout
.abandoned => emit(1, "\n"), // ^C: readline drew nothing, fsh ends the line
.line => |l| {
emit(1, "\n") // readline submits without echoing the CR
dispatch(l)
// A full-screen child (a future TUI tool) may have left the
// kernel console in raw / masked / alt mode; reset it so the
// next prompt + readline behave.
_ = flibc.sys.set_console_mode(0)
// Blank line after a real command's output, before the next
// prompt; skipped on a bare Enter so empty lines don't double up.
if trim(l).len != 0 {
emit(1, "\n")
}
},
}
}
}
// ---- dispatch ----
fn dispatch(line []u8) {
var argv [tok.MAX_ARGS]?[*:0]mut u8 = undefined
var buf [TOK_BUF]u8 = undefined
switch tok.tokenize(line, &argv, &buf) {
.empty => {},
.err => |e| switch e {
.too_many_pipes => emit(2, "fsh: only one pipe supported\n"),
.empty_side => emit(2, "fsh: missing command around |\n"),
},
.single => |n| runSingle(&argv, n),
.piped => |p| runPiped(&argv, p),
}
}
fn runSingle(argv *mut [tok.MAX_ARGS]?[*:0]mut u8, argc usize) {
name := argv[0] orelse return
if runBuiltin(name, argv, argc) {
return
}
pid := flibc.fork()
if pid == 0 {
_ = flibc.execvp(name, #ptrCast(argv))
emit(2, "fsh: command not found\n") // execvp only returns on failure
flibc.exit()
} else if pid > 0 {
_ = flibc.wait()
} else {
emit(2, "fsh: fork failed\n")
}
}
// One pipe stage. argv holds both vectors back to back, separated by the
// `null` the tokenizer wrote at the boundary: left = argv[0..], right =
// argv[left_argc + 1 ..]. Wire wfd→stdout in the left child, rfd→stdin
// in the right child, close both ends everywhere, and reap both.
fn runPiped(argv *mut [tok.MAX_ARGS]?[*:0]mut u8, p tok.Piped) {
const left [*]?[*:0]u8 = #ptrCast(argv)
const right [*]?[*:0]u8 = #ptrCast(&argv[p.left_argc + 1])
pipe_packed := flibc.sys.pipe()
if pipe_packed < 0 {
emit(2, "fsh: pipe failed\n")
return
}
const up u64 = #bitCast(pipe_packed)
const rfd i32 = #intCast(up & 0xffffffff)
const wfd i32 = #intCast(up >> 32)
lpid := flibc.fork()
if lpid == 0 {
_ = flibc.sys.dup2(wfd, 1)
_ = flibc.sys.close(rfd)
_ = flibc.sys.close(wfd)
_ = flibc.execvp(left[0].?, left)
flibc.exit()
}
if lpid < 0 {
// No child exists yet: close both ends, do not reap.
emit(2, "fsh: fork failed\n")
_ = flibc.sys.close(rfd)
_ = flibc.sys.close(wfd)
return
}
rpid := flibc.fork()
if rpid == 0 {
_ = flibc.sys.dup2(rfd, 0)
_ = flibc.sys.close(rfd)
_ = flibc.sys.close(wfd)
_ = flibc.execvp(right[0].?, right)
flibc.exit()
}
if rpid < 0 {
// Left child is already running: close both ends, reap it once.
emit(2, "fsh: fork failed\n")
_ = flibc.sys.close(rfd)
_ = flibc.sys.close(wfd)
_ = flibc.wait()
return
}
// Shell holds neither end open, else the right child never sees EOF.
_ = flibc.sys.close(rfd)
_ = flibc.sys.close(wfd)
// Both pids are > 0 here, so reap both children unconditionally.
_ = flibc.wait()
_ = flibc.wait()
}
// ---- built-ins (in-process, no fork) ----
fn runBuiltin(name [*:0]u8, argv *mut [tok.MAX_ARGS]?[*:0]mut u8, argc usize) bool {
if streq(name, "exit") || streq(name, "logout") {
flibc.exit()
}
if streq(name, "reboot") {
flibc.sys.reboot()
}
if streq(name, "help") {
emit(1, HELP_TEXT)
listBin()
return true
}
if streq(name, "cd") {
const target [*:0]u8 = if (argc >= 2) argv[1].? else "/"
if flibc.chdir(target) < 0 {
emit(2, "cd: cannot change directory\n")
}
return true
}
if streq(name, "pwd") {
var buf [CWD_MAX]u8 = undefined
n := flibc.sys.getcwd(&buf, buf.len)
if n < 0 {
emit(2, "pwd: cannot read working directory\n")
} else {
emit(1, buf[0..#intCast(n)])
emit(1, "\n")
}
return true
}
if streq(name, "free") {
flibc.printf("free pages: %u\n", .{flibc.sys.dump_free()})
return true
}
if streq(name, "whoami") {
whoami()
return true
}
return false
}
// List /bin so `help` advertises the external commands without a hardcoded
// catalog — a new tool shows up by existing (and TAB completes it too). The
// Dirent lives on the stack (rule 1); a missing /bin simply lists nothing.
fn listBin() {
emit(1, "Programs in /bin:\n ")
var d flibc.Dirent = .{}
var i u64 = 0
while flibc.sys.readdir("/bin", i, &d) == 0 {
var n usize = 0
while n < d.name.len && d.name[n] != 0 {
n += 1
}
emit(1, " ")
emit(1, d.name[0..n])
i += 1
}
emit(1, "\n")
}
// Print the login name matching the real uid, resolved against
// /etc/passwd through the shared pwfile parser (the same module the
// kernel and /bin/login use). Falls back to the numeric uid when the
// file is unreadable or the uid has no entry — a dropped uid without an
// account is still identifiable. Stack buffer only (rule 1).
fn whoami() {
uid_raw := flibc.sys.getuid()
if uid_raw < 0 {
emit(2, "whoami: cannot read uid\n")
return
}
const uid u32 = #intCast(uid_raw)
fd := flibc.sys.open("/etc/passwd")
if fd >= 0 {
var buf [PASSWD_MAX]u8 = undefined
var n usize = 0
while n < buf.len {
r := flibc.sys.read(fd, buf[n..].ptr, buf.len - n)
if r <= 0 {
break
}
n += #intCast(r)
}
_ = flibc.sys.close(fd)
if pwfile.lookupByUid(buf[0..n], uid) |entry| {
emit(1, entry.user)
emit(1, "\n")
return
}
}
flibc.printf("%u\n", .{#as(u64, uid)})
}
fn streq(a [*:0]u8, b []u8) bool {
var i usize = 0
while i < b.len {
if a[i] != b[i] {
return false
}
i += 1
}
return a[b.len] == 0
}