// 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: [args] pipe: | \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 }