ajhahn.de
← Flash
Flash 360 lines
// fsh — the FlashOS shell, fully ported to Flash from its hand-written Zig. fsh
// is a line-at-a-time REPL over the unified fd ABI: `main` runs /etc/fshrc then
// enters `repl`, which prints the homescreen once and then loops — pick the
// prompt by euid, read a line with the readline editor, and `dispatch` it.
// `dispatch` tokenizes the line (one optional `|`) and routes it: a built-in
// runs in-process (`runBuiltin` — cd / pwd / free / whoami / reboot / exit /
// logout / help), and an external command is forked + execvp'd as one program
// (`runSingle`) or a single pipe stage (`runPiped` — dup2 the pipe ends and reap
// both children). `listBin` / `whoami` walk /bin and resolve a uid against
// /etc/passwd; `trim` / `isSpace`, `streq` / `emit` are the small helpers.
//
// This leaf completes fsh by porting the entry shim and REPL driver (`main` /
// `repl`), which needed the `align(N)` binding qualifier: the REPL's >16-byte
// `Completion` is materialised as `const comp flibc.Completion align(16) = …`,
// so LLVM's strict-align NEON store (a 16-byte `str q` into an 8-aligned slot
// under SCTLR_EL1.A) cannot fault. The `.line => |l| { … }` capture-and-block
// switch prong it also needs landed with `dispatch`.
//
// Pure plumbing: no allocator, no module-level mutable state — every buffer
// (line / argv / scratch / history ring) is function-local on the user stack,
// so the single R+X PT_LOAD has no .bss to write. Reused surface throughout: the
// `.?` optional-unwrap (`left[0].?`, `argv[1].?`), `orelse`, the `*mut
// [N]?[*:0]mut u8` argv vector, sentinel pointers, value-form `if`, the `argv`
// spelling alias, `#ptrCast` / `#bitCast` / `#intCast`, and the `++` folds of
// HELP_TEXT. Its core lowers to Zig whose token stream matches the reference.

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)
const HIST_N usize = 16 // command-history ring depth; slots live on repl's stack frame

// Unix-style privilege prompt: `# ` for root (euid 0), `$ ` otherwise — selected
// per REPL iteration via geteuid.
const PROMPT_ROOT = "# "
const PROMPT_USER = "$ "
// Homescreen banner credit; the version comes from build_options (no literal here).
const AUTHOR = "ajhahnde"

// The `help` built-in's command summary, folded from per-line literals.
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" }

// Entry: the flibc _start shim forwards here. Run the startup file, then the
// interactive REPL; `exit` ends the session (argv is ignored).
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) for the shared renderers.
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.
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
    for i in 0..content.len + 1 {
        if i == content.len || content[i] == '\n' {
            line := trim(content[start..i])
            if line.len != 0 && line[0] != '#' {
                dispatch(line)
            }
            start = 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 ----

// The interactive loop: print the homescreen once, then per iteration select the
// prompt by euid, read a line with the readline editor, and dispatch it. `.eof`
// (^D on an empty line) ends the session; `.abandoned` (^C) just ends the line.
fn repl() {
    var line_buf [LINE_MAX]u8 = undefined
    // Caller-owned history ring (no allocator — function-local); slots are
    // written by the editor before they are read back.
    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)
        // align(16): the >16-byte Completion is materialised on this frame, and
        // LLVM may SLP-store its adjacent slice fields with a 16-byte NEON `str q`
        // 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,
            .abandoned => emit(1, "\n"),
            .line => |l| {
                emit(1, "\n")
                dispatch(l)
                // A full-screen child may have left the console in raw / alt mode;
                // reset it so the next prompt + readline behave.
                _ = flibc.sys.set_console_mode(0)
                if trim(l).len != 0 {
                    emit(1, "\n")
                }
            },
        }
    }
}

// ---- dispatch ----

// Tokenize one line and route it: an empty line is a no-op, a tokenizer error
// prints to stderr, and a valid command runs as a single program or a single
// pipe stage. The argv vector and tokenizer scratch live on this frame (rule 1).
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),
    }
}

// ---- command execution (fork + exec) ----

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
// catalogue — 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 {
    for i in 0..b.len {
        if a[i] != b[i] {
            return false
        }
    }
    return a[b.len] == 0
}