#![no_std] //! Command shell — reads from PS/2 keyboard, writes to GOP + serial. //! //! One canonical grammar for every command (ADR 0002): //! //! ```text //! cmd args [USE src] [TIL marker] [filters…] [output kw dst] //! ``` //! //! - **Keywords** (uppercase-only, reserved operators): `WRITING`/`WRITE` //! overwrite a file, `TO` appends, `USE`/`USING` names an input source, //! `TIL`/`UNTIL` ends input at an exact full-line marker. //! - **Filters** are trailing flags applied left to right as stages over a //! line stream: `-h N` (first N lines, then kill the upstream), //! `-t N` (hold back a rolling window of the last N lines, emit only at //! end of stream), `-g PAT` (smart-case literal substring), `-p` (pause //! at each screenful). Each may repeat. //! - **Quoting** (`"..."` groups + literalizes, `\x` escapes one char) //! makes quoted/escaped text literal — never an operator, never split. //! - `>` and friends are gone; `>` is ordinary text. //! - ^D is EOF everywhere and never powers off; `poweroff`/`exit` are the //! only shutdown verbs. extern crate alloc; use alloc::collections::VecDeque; use alloc::string::{String, ToString}; use alloc::vec::Vec; use rsnix::tty::{self, LineInput}; use rsnix::{gop_print, gop_println}; /// Run the shell forever. pub fn run() -> ! { gop_println!( "rs-dos v{} — type 'help' for commands", env!("CARGO_PKG_VERSION") ); term_println(&alloc::format!( "========== rs-dos v{} ==========", env!("CARGO_PKG_VERSION") )); loop { // Push any deferred scrolls so the prompt renders on a current // framebuffer (e.g. after a streaming command's batch of output). rsnix::vt::sync_scroll(); // Cheap hotplug probe: if any AHCI port's device presence changed // since the last scan, re-run discovery (mounts new volumes, // unmounts gone ones) before showing the prompt. if rsnix::ahci::poll_dirty() { report_rescan(rescan_all()); } // Print prompt term_print("> "); // Read one line from keyboard (tty canonical mode) match tty::read_line() { LineInput::Interrupted => continue, // ^C — fresh prompt LineInput::Eof => continue, // ^D on an empty line — fresh prompt (no poweroff) LineInput::Line(line) => { let trimmed = line.trim(); if trimmed.is_empty() { continue; } execute(trimmed); } } } } // --------------------------------------------------------------------------- // Diagnostics and command output go straight to terminal + serial, never // through the filter pipeline and never into a file sink. These helpers // write to the serial port directly — NOT via `serial_println!`, which // feeds the klog ring — so user-facing shell output (prompts, echoes, // command results) never lands in the boot log. // --------------------------------------------------------------------------- fn term_print(s: &str) { gop_print!("{}", s); rsnix::serial::write_str(s); } fn term_println(s: &str) { term_print(s); gop_println!(); rsnix::serial::write_str("\r\n"); } fn term_err(msg: &str) { term_println(msg); } // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- /// One lexical token: its text plus whether any quoting/escaping was used /// (a token containing quoted/escaped text is never an operator or flag). struct Token { text: String, literal: bool, } /// Split a command line into words, handling `"..."` groups and `\x` /// escapes. Quotes are delimiters, not content; escapes prevent splitting /// and literalize one character (`\"` → `"`, `\ ` → space). fn tokenize(s: &str) -> Result, &'static str> { let mut toks: Vec = Vec::new(); let mut cur = String::new(); let mut cur_literal = false; let mut in_quote = false; let mut chars = s.chars(); while let Some(c) = chars.next() { match c { '"' => { cur_literal = true; in_quote = !in_quote; } '\\' => { cur_literal = true; match chars.next() { Some(e) => cur.push(e), None => cur.push('\\'), // trailing backslash: literal } } c if c.is_whitespace() && !in_quote => { if !cur.is_empty() { toks.push(Token { text: core::mem::take(&mut cur), literal: cur_literal, }); cur_literal = false; } } c => cur.push(c), } } if in_quote { return Err("unterminated quote"); } if !cur.is_empty() { toks.push(Token { text: cur, literal: cur_literal, }); } Ok(toks) } fn is_keyword(s: &str) -> bool { matches!( s, "WRITING" | "WRITE" | "TO" | "USE" | "USING" | "TIL" | "UNTIL" ) } /// How the command's input is fed. Only `cat`/`hexdump`, and only when /// path-less, may carry an input keyword. #[derive(Clone)] enum Input { None, /// `USE f` — read from a file. Use(String), /// `TIL m` — read from the keyboard until an exact line equals `m`. Til(String), /// `USE f TIL m` — read `f` until an exact line equals `m`. UseTil(String, String), } impl Input { fn present(&self) -> bool { !matches!(self, Input::None) } } /// A post-filter stage. Applied left to right over the line stream. #[derive(Clone)] enum Filter { Head(usize), Tail(usize), Grep(String), Pause, } /// Output destination. #[derive(Clone)] enum Sink { Write(String), Append(String), } struct Parsed { cmd: String, args: Vec, input: Input, filters: Vec, output: Option, } /// Parse one command line into the canonical grammar. Errors are the /// syntax-error message to show the user. fn parse(s: &str) -> Result { let toks = tokenize(s).map_err(|e| e.to_string())?; if toks.is_empty() { return Err("empty command".into()); } let cmd = toks[0].text.clone(); let mut i = 1; let mut args: Vec = Vec::new(); let mut filters: Vec = Vec::new(); let mut output: Option = None; let mut trailing = false; // a filter flag or output keyword has been seen let mut src: Option = None; let mut til: Option = None; let mut til_seen = false; while i < toks.len() { let t = &toks[i]; let text = t.text.as_str(); if !t.literal { match text { "USE" | "USING" => { if output.is_some() { return Err("USE must come before the output keyword".into()); } if trailing { return Err("USE must come before the filters".into()); } if src.is_some() || til_seen { return Err("duplicate input keyword".into()); } i += 1; let path = toks.get(i).ok_or("USE needs a source path")?; if !path.literal && is_keyword(&path.text) { return Err("USE needs a source path".into()); } src = Some(path.text.clone()); i += 1; continue; } "TIL" | "UNTIL" => { if output.is_some() { return Err("TIL must come before the output keyword".into()); } if trailing { return Err("TIL must come before the filters".into()); } if til_seen { return Err("duplicate TIL".into()); } i += 1; let m = toks.get(i).ok_or("TIL needs a marker")?; if !m.literal && is_keyword(&m.text) { return Err("TIL needs a marker".into()); } til = Some(m.text.clone()); til_seen = true; i += 1; continue; } "WRITING" | "WRITE" | "TO" => { if output.is_some() { return Err("duplicate output keyword".into()); } i += 1; let dst = toks.get(i).ok_or("output keyword needs a destination")?; if !dst.literal && is_keyword(&dst.text) { return Err("output keyword needs a destination".into()); } output = Some(if text == "TO" { Sink::Append(dst.text.clone()) } else { Sink::Write(dst.text.clone()) }); trailing = true; i += 1; continue; } "-h" | "-t" => { if output.is_some() { return Err("filters must come before the output keyword".into()); } trailing = true; i += 1; let n = toks.get(i).ok_or("flag needs a count")?; let n: usize = n.text.parse().map_err(|_| "count must be a number ≥ 1")?; if n < 1 { return Err("count must be ≥ 1".into()); } filters.push(if text == "-h" { Filter::Head(n) } else { Filter::Tail(n) }); i += 1; continue; } "-g" => { if output.is_some() { return Err("filters must come before the output keyword".into()); } trailing = true; i += 1; let pat = toks.get(i).ok_or("-g needs a pattern")?; filters.push(Filter::Grep(pat.text.clone())); i += 1; continue; } "-p" => { if output.is_some() { return Err("filters must come before the output keyword".into()); } trailing = true; filters.push(Filter::Pause); i += 1; continue; } _ => {} } } // Ordinary token. Once the trailing region has started (a filter // flag or the output keyword), stray arguments are syntax errors. if trailing { return Err(alloc::format!("unexpected argument '{}'", text)); } args.push(text.to_string()); i += 1; } let input = match (src, til) { (Some(s), Some(t)) => Input::UseTil(s, t), (Some(s), None) => Input::Use(s), (None, Some(t)) => Input::Til(t), (None, None) => Input::None, }; Ok(Parsed { cmd, args, input, filters, output, }) } // --------------------------------------------------------------------------- // Filter pipeline + output sink // --------------------------------------------------------------------------- /// Per-stage mutable state. enum StageState { /// Lines still allowed through before the upstream is killed. Head(usize), /// Rolling window of the last N lines. Tail(VecDeque), Grep, Pause { shown: usize, screen: usize, }, } /// Where the filtered line stream ends up. enum OutSink { Term, File { path: String, h: Option, pos: u64, overwrite: bool, }, } /// Smart-case literal substring match: case-insensitive unless the pattern /// contains an uppercase letter. fn smart_match(pat: &str, line: &str) -> bool { if pat.chars().any(|c| c.is_uppercase()) { line.contains(pat) } else { let lp = pat.to_ascii_lowercase(); let ll = line.to_ascii_lowercase(); ll.contains(&lp) } } /// Pager pause: terminal only, waits for a keypress; ^C aborts the stream. fn pager_wait() -> bool { gop_print!("\n— more —"); match tty::read_byte() { 0x03 => { // ^C — abort the stream (stream-abort role). gop_println!(); false } _ => { // Any key continues; swallow queued tail bytes (e.g. the // rest of an arrow-key escape sequence). while tty::try_read_byte().is_some() {} true } } } /// The filter chain plus the output sink. Producers push one line at a /// time; `line()` returns false when the upstream must stop (head limit or /// pager ^C). At end of stream call `end()` to flush held-back tails. struct Pipeline { stages: Vec<(Filter, StageState)>, sink: OutSink, } impl Pipeline { fn new(filters: &[Filter], output: &Option) -> Self { let file_sink = output.is_some(); let mut stages: Vec<(Filter, StageState)> = Vec::new(); for f in filters { // With a file sink the pager is a no-op. if file_sink && matches!(f, Filter::Pause) { continue; } let st = match f { Filter::Head(n) => StageState::Head(*n), Filter::Tail(n) => StageState::Tail(VecDeque::with_capacity(*n)), Filter::Grep(_) => StageState::Grep, Filter::Pause => StageState::Pause { shown: 0, screen: rsnix::vt::size().0.saturating_sub(1).max(1), }, }; stages.push((f.clone(), st)); } let sink = match output { Some(Sink::Write(p)) => OutSink::File { path: p.clone(), h: None, pos: 0, overwrite: true, }, Some(Sink::Append(p)) => OutSink::File { path: p.clone(), h: None, pos: 0, overwrite: false, }, None => OutSink::Term, }; Pipeline { stages, sink } } /// Push one line through the stages. Returns false when the producer /// must stop (head killed the stream, or ^C at the pager). fn line(&mut self, l: &str) -> bool { self.push_through(0, l) } /// Run one line through the stages starting at `start`, delivering to /// the sink. `start` lets `finish()` replay a drained tail window /// through only the stages after it. fn push_through(&mut self, start: usize, l: &str) -> bool { let mut cur = alloc::vec![l.to_string()]; for (i, (f, st)) in self.stages.iter_mut().enumerate() { if i < start { continue; } let mut out = Vec::new(); match f { Filter::Head(_) => { let StageState::Head(rem) = st else { unreachable!("stage state does not match filter") }; if *rem == 0 { // Head limit reached: kill the upstream. The line // that would have overflowed is dropped. return false; } for l in cur.drain(..) { *rem -= 1; out.push(l); } } Filter::Tail(n) => { let StageState::Tail(buf) = st else { unreachable!("stage state does not match filter") }; for l in cur.drain(..) { if buf.len() == *n { buf.pop_front(); } buf.push_back(l); } // Held back — nothing reaches the downstream yet. return true; } Filter::Grep(pat) => { let StageState::Grep = st else { unreachable!("stage state does not match filter") }; for l in cur.drain(..) { if smart_match(pat, &l) { out.push(l); } } } Filter::Pause => { let StageState::Pause { shown, screen } = st else { unreachable!("stage state does not match filter") }; for l in cur.drain(..) { if *shown > 0 && *shown % *screen == 0 && !pager_wait() { return false; } *shown += 1; out.push(l); } } } cur = out; } for l in &cur { self.emit(l); } true } /// Flush held-back tail windows through the remaining stages. Called /// at end of stream: EOF, ^C, or after a head kill. fn finish(&mut self) { loop { let mut any = false; for i in 0..self.stages.len() { let drained: Vec = match &mut self.stages[i] { (Filter::Tail(_), StageState::Tail(buf)) if !buf.is_empty() => { buf.drain(..).collect() } _ => continue, }; any = true; for l in drained { self.push_through(i + 1, &l); } } if !any { break; } } } /// End of stream: flush tails, done. File writes are synchronous. fn end(&mut self) { self.finish(); } fn emit(&mut self, l: &str) { match &self.sink { OutSink::Term => { term_println(l); } OutSink::File { .. } => {} } if let OutSink::File { h: None, .. } = &self.sink { self.open_sink(); } if let OutSink::File { h: Some(h), pos, .. } = &mut self.sink { let mut bytes = l.as_bytes().to_vec(); bytes.push(b'\n'); if let Ok(n) = rsnix::fs::write(*h, &bytes, *pos) { *pos += n as u64; } } } /// Lazily open the file sink on first output (a command that emits /// nothing touches no file). WRITING truncates and writes from offset /// 0 (the node's cached size is stale after truncation); TO appends at /// the file's current size. fn open_sink(&mut self) { let (path, overwrite) = match &self.sink { OutSink::File { path, overwrite, .. } => (path.clone(), *overwrite), _ => return, }; match rsnix::fs::ensure_file(&path) { Ok(h) => { let pos = if overwrite { let _ = rsnix::fs::set_len(h, 0); 0 } else { rsnix::fs::size(h) }; self.sink = OutSink::File { path, h: Some(h), pos, overwrite, }; } Err(()) => { term_err(&alloc::format!("cannot write '{}'", path)); self.sink = OutSink::Term; } } } } /// Emit every line of a pre-built multi-line string (interior blank lines /// preserved; a trailing newline does not produce an extra empty line). fn emit_string(pipe: &mut Pipeline, s: &str) { let mut rest = s; loop { match rest.find('\n') { Some(idx) => { if !pipe.line(&rest[..idx]) { return; } rest = &rest[idx + 1..]; } None => { if !rest.is_empty() { let _ = pipe.line(rest); } return; } } } } // --------------------------------------------------------------------------- // Execution // --------------------------------------------------------------------------- /// Commands that may consume an input stream (`USE`/`TIL`). fn accepts_input(cmd: &str) -> bool { matches!(cmd, "cat" | "hexdump") } fn execute(s: &str) { let parsed = match parse(s) { Ok(p) => p, Err(e) => { term_err(&e); return; } }; let cmd = parsed.cmd.as_str(); if parsed.input.present() { if !accepts_input(cmd) { term_err(&alloc::format!("{}: does not accept input", cmd)); return; } // `cat TIL ` is sugar for `cat USE TIL // `: the paths are the input source, truncated at the // marker. An explicit source keyword (USE / USE..TIL) alongside // path arguments is genuinely two input sources. if !parsed.args.is_empty() && !matches!(parsed.input, Input::Til(_)) { term_err(&alloc::format!( "{}: input source keyword with path arguments is ambiguous — \ the paths are the input; drop the keyword, or to truncate them at a marker \ write `{} TIL `", cmd, cmd )); return; } } match cmd { "help" => cmd_help(&parsed), "echo" => cmd_echo(&parsed), "clear" => cmd_clear(), "halt" => cmd_halt(), "poweroff" | "exit" => cmd_poweroff(), "hello" => cmd_hello(&parsed), "info" => cmd_info(&parsed), "mem" => cmd_mem(&parsed), "mmap" => cmd_mmap(&parsed), "stress" => cmd_stress(&parsed), "date" => cmd_date(&parsed), "pci" => cmd_pci(&parsed), "rescan" => cmd_rescan(&parsed), "ls" => cmd_ls(&parsed), "cat" => cmd_cat(&parsed), "klog" => cmd_klog(&parsed), "hexdump" => cmd_hexdump(&parsed), "mkdir" => cmd_mkdir(&parsed), "touch" => cmd_touch(&parsed), "rm" => cmd_rm(&parsed), "edit" => cmd_edit(&parsed), _ => term_err(&alloc::format!("unknown command: {}", cmd)), } } // --------------------------------------------------------------------------- // Built-in commands // --------------------------------------------------------------------------- fn cmd_help(_p: &Parsed) { let mut pipe = Pipeline::new(&_p.filters, &_p.output); let text = "available commands: help — show this message echo — echo arguments clear — clear screen hello — print 'World!' info — show system info mem — show memory info mmap — dump the UEFI memory map stress — stress-test heap/page allocators date — show current date/time (RTC) pci — list PCI devices rescan — re-scan for hotplugged/removed disks (also runs automatically) ls — list a directory cat — print files (cat USE a TO b copies a→b; cat TIL END is a heredoc) klog — kernel boot log (cat /dev/klog; WRITING persists it) hexdump — hex-dump a file or input stream mkdir — create a directory touch — create an empty file rm — remove a file or empty dir edit — full-screen editor (^S save, ^X exit, ^W search) halt — halt the system (hlt loop) poweroff — shut down via ACPI (ResetSystem) exit — alias for poweroff redirection keywords (uppercase, after args): USE/USING read command input from a file (cat/hexdump only) TIL/UNTIL read input until an exact marker line (heredoc) cat TIL reads the path to the marker WRITING/WRITE write output to a file (overwrite) TO append output to a file order: USE/TIL first, then the -flags, then WRITING/TO last a file sink replaces the screen; > >> < << are gone (> is plain text) post-filter flags (trailing, applied left to right): -h N first N lines (kills the command after N) -t N last N lines (shows nothing until the stream ends; ^C flushes) -g PAT smart-case literal substring match (repeatable) -p pause at each screenful (more-style; ^C aborts) quoting: \"...\" groups text; \\x escapes one char. Quoted text is never an operator: echo \"WRITING\" prints WRITING. ^D = EOF; ^C cancels a line or aborts a stream. poweroff/exit shut down."; emit_string(&mut pipe, text); pipe.end(); } fn cmd_echo(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); let line = p.args.join(" "); let _ = pipe.line(&line); pipe.end(); } fn cmd_hello(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); let _ = pipe.line("World!"); pipe.end(); } fn cmd_clear() { rsnix::gop::clear_screen(); } fn cmd_info(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); let mut s = String::from("rs-dos v"); s.push_str(env!("CARGO_PKG_VERSION")); s.push_str(" — rsnix kernel"); let _ = pipe.line(&s); let _ = pipe.line("arch: x86_64, freestanding, no userspace"); pipe.end(); } fn cmd_ls(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or("/"); let mut pipe = Pipeline::new(&p.filters, &p.output); match rsnix::fs::resolve(path) { None => term_err(&alloc::format!("ls: {}: no such file or directory", path)), Some(h) => { let list = if rsnix::fs::kind(h) == rsnix::fs::FileType::Dir { rsnix::fs::children(h) } else { alloc::vec![( path.rsplit('/').next().unwrap_or(path).to_string(), rsnix::fs::kind(h), rsnix::fs::size(h) )] }; for (name, kind, size) in list { let l = match kind { rsnix::fs::FileType::Dir => alloc::format!("{}/", name), rsnix::fs::FileType::Char => alloc::format!("{}@", name), rsnix::fs::FileType::Regular => alloc::format!("{} {} B", name, size), }; if !pipe.line(&l) { break; } } } } pipe.end(); } // --------------------------------------------------------------------------- // klog — kernel log (thin alias for `cat /dev/klog`) // --------------------------------------------------------------------------- fn cmd_klog(p: &Parsed) { if !p.args.is_empty() { term_err("klog: usage: klog [-h N] [-t N] [-g PAT] [-p] [WRITING | TO ]"); return; } // Alias for `cat /dev/klog` — same filters and redirection, so // `klog -t 5` shows the last 5 log lines and `klog WRITING // /disk/.../boot.log` persists the boot log. Input keywords were // already rejected (klog does not accept input). let q = Parsed { cmd: alloc::string::String::from("klog"), args: alloc::vec!["/dev/klog".into()], input: Input::None, filters: p.filters.clone(), output: p.output.clone(), }; cmd_cat(&q); } // --------------------------------------------------------------------------- // cat — file and stream consumers // --------------------------------------------------------------------------- /// How a file read ended: read to EOF, stopped at the TIL marker, or /// aborted (^C / head kill) — the caller should stop processing paths. enum ReadResult { Done, Marker, Aborted, } fn cmd_cat(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); match &p.input { Input::None => { if p.args.is_empty() { // Path-less `cat` with no input keyword reads the keyboard // until ^D. cat_stdin(&mut pipe); } else { for path in &p.args { if !cat_file(&mut pipe, path) { break; } } } } Input::Use(path) => { cat_file(&mut pipe, path); } Input::Til(marker) => { if p.args.is_empty() { cat_stdin_until(&mut pipe, marker); } else { // `cat TIL ` — the paths are the input // source, truncated at the first marker line. for path in &p.args { match cat_file_until(&mut pipe, path, marker) { ReadResult::Done => {} ReadResult::Marker | ReadResult::Aborted => break, } } } } Input::UseTil(path, marker) => { cat_file_until(&mut pipe, path, marker); } } pipe.end(); } fn cat_stdin(pipe: &mut Pipeline) -> bool { loop { match tty::read_line() { LineInput::Line(l) => { if !pipe.line(l.as_str()) { return false; } } LineInput::Interrupted => return false, // ^C aborts the stream LineInput::Eof => return true, // ^D = EOF } } } fn cat_stdin_until(pipe: &mut Pipeline, marker: &str) -> bool { loop { match tty::read_line() { LineInput::Line(l) => { // Marker line is not part of the input. if l.as_str() == marker { return true; } if !pipe.line(l.as_str()) { return false; } } LineInput::Interrupted => return false, LineInput::Eof => return true, } } } fn cat_file(pipe: &mut Pipeline, path: &str) -> bool { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("cat: {}: no such file", path)); return true; // keep going with the remaining paths } }; let mut buf = [0u8; 512]; let mut pos = 0u64; let mut pending = String::new(); loop { if tty::interrupt_pressed() { return false; // ^C aborts everything } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; pending.push_str(&String::from_utf8_lossy(&buf[..n])); while let Some(idx) = pending.find('\n') { let line: String = pending.drain(..=idx).collect(); let line = line.trim_end_matches('\n'); if !pipe.line(line) { return false; } } } } } if !pending.is_empty() && !pipe.line(&pending) { return false; } true } fn cat_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> ReadResult { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("cat: {}: no such file", path)); return ReadResult::Done; } }; let mut buf = [0u8; 512]; let mut pos = 0u64; let mut pending = String::new(); let mut stopped = false; 'outer: loop { if tty::interrupt_pressed() { return ReadResult::Aborted; } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; pending.push_str(&String::from_utf8_lossy(&buf[..n])); while let Some(idx) = pending.find('\n') { let line: String = pending.drain(..=idx).collect(); let line = line.trim_end_matches('\n'); // Marker line and everything after is discarded. if line == marker { stopped = true; break 'outer; } if !pipe.line(line) { return ReadResult::Aborted; } } } } } if !stopped && !pending.is_empty() && pending != marker && !pipe.line(&pending) { return ReadResult::Aborted; } if stopped { ReadResult::Marker } else { ReadResult::Done } } // --------------------------------------------------------------------------- // hexdump — hex-dump files and input streams // --------------------------------------------------------------------------- struct HexCtx<'a> { pipe: &'a mut Pipeline, pos: u64, row: [u8; 16], row_len: usize, alive: bool, } fn format_row(addr: u64, row: &[u8; 16]) -> String { let mut out = String::new(); use core::fmt::Write as _; let _ = core::write!(out, "{:08x} ", addr); for &b in row { let _ = core::write!(out, "{:02x} ", b); } out.push_str(" |"); for &b in row { out.push(if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' }); } out.push('|'); out } fn format_partial(addr: u64, row: &[u8]) -> String { let mut out = String::new(); use core::fmt::Write as _; let _ = core::write!(out, "{:08x} ", addr); for i in 0..16 { if i < row.len() { let _ = core::write!(out, "{:02x} ", row[i]); } else { out.push_str(" "); } } out.push_str(" |"); for &b in row { out.push(if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' }); } out.push('|'); out } impl HexCtx<'_> { fn feed(&mut self, data: &[u8]) { if !self.alive { return; } for &b in data { self.row[self.row_len] = b; self.row_len += 1; if self.row_len == 16 { let line = format_row(self.pos, &self.row); self.alive = self.pipe.line(&line); self.pos += 16; self.row_len = 0; if !self.alive { return; } } } } /// Emit the trailing partial row, if any. fn flush(&mut self) { if self.alive && self.row_len > 0 { let line = format_partial(self.pos, &self.row[..self.row_len]); let _ = self.pipe.line(&line); } } } fn cmd_hexdump(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); match &p.input { Input::None => { if p.args.is_empty() { hexdump_stdin(&mut pipe); } else { for path in &p.args { if !hexdump_file(&mut pipe, path) { break; } } } } Input::Use(path) => { hexdump_file(&mut pipe, path); } Input::Til(marker) => { if p.args.is_empty() { hexdump_stdin_until(&mut pipe, marker); } else { // `hexdump TIL ` — the paths are the input // source, truncated at the first marker line. for path in &p.args { match hexdump_file_until(&mut pipe, path, marker) { ReadResult::Done => {} ReadResult::Marker | ReadResult::Aborted => break, } } } } Input::UseTil(path, marker) => { hexdump_file_until(&mut pipe, path, marker); } } pipe.end(); } fn hexdump_file(pipe: &mut Pipeline, path: &str) -> bool { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("hexdump: {}: no such file", path)); return true; } }; let mut ctx = HexCtx { pipe, pos: 0, row: [0u8; 16], row_len: 0, alive: true, }; let mut buf = [0u8; 512]; let mut pos = 0u64; loop { if tty::interrupt_pressed() { ctx.flush(); return false; } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; ctx.feed(&buf[..n]); if !ctx.alive { break; } } } } ctx.flush(); ctx.alive } fn hexdump_stdin(pipe: &mut Pipeline) { let mut ctx = HexCtx { pipe, pos: 0, row: [0u8; 16], row_len: 0, alive: true, }; loop { match tty::read_line() { LineInput::Line(l) => { ctx.feed(l.as_str().as_bytes()); if !ctx.alive { break; } ctx.feed(b"\n"); if !ctx.alive { break; } } LineInput::Interrupted => break, LineInput::Eof => break, } } ctx.flush(); } fn hexdump_stdin_until(pipe: &mut Pipeline, marker: &str) { let mut ctx = HexCtx { pipe, pos: 0, row: [0u8; 16], row_len: 0, alive: true, }; loop { match tty::read_line() { LineInput::Line(l) => { if l.as_str() == marker { break; } ctx.feed(l.as_str().as_bytes()); if !ctx.alive { break; } ctx.feed(b"\n"); if !ctx.alive { break; } } LineInput::Interrupted => break, LineInput::Eof => break, } } ctx.flush(); } fn hexdump_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> ReadResult { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("hexdump: {}: no such file", path)); return ReadResult::Done; } }; let mut ctx = HexCtx { pipe, pos: 0, row: [0u8; 16], row_len: 0, alive: true, }; let mut buf = [0u8; 512]; let mut pos = 0u64; let mut pending = String::new(); let mut stopped = false; 'outer: loop { if tty::interrupt_pressed() { break; } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; pending.push_str(&String::from_utf8_lossy(&buf[..n])); while let Some(idx) = pending.find('\n') { let line: String = pending.drain(..=idx).collect(); let line = line.trim_end_matches('\n'); if line == marker { stopped = true; break 'outer; } ctx.feed(line.as_bytes()); ctx.feed(b"\n"); if !ctx.alive { break 'outer; } } } } } if ctx.alive && !stopped && !pending.is_empty() && pending != marker { ctx.feed(pending.as_bytes()); } ctx.flush(); if !ctx.alive { ReadResult::Aborted } else if stopped { ReadResult::Marker } else { ReadResult::Done } } // --------------------------------------------------------------------------- // Other commands // --------------------------------------------------------------------------- fn cmd_mkdir(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { term_err("usage: mkdir "); return; } match rsnix::fs::mkdir(path) { Ok(()) => {} Err(()) => term_err(&alloc::format!("mkdir: cannot create directory '{}'", path)), } } fn cmd_touch(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { term_err("usage: touch "); return; } match rsnix::fs::touch(path) { Ok(()) => {} Err(()) => term_err(&alloc::format!("touch: cannot create '{}'", path)), } } fn cmd_rm(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { term_err("usage: rm "); return; } match rsnix::fs::remove_path(path) { Ok(()) => {} Err(()) => term_err(&alloc::format!( "rm: cannot remove '{}' (missing, dir not empty, or busy)", path )), } } fn cmd_edit(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { term_err("usage: edit "); return; } if rsnix::fs::ensure_file(path).is_err() { term_err(&alloc::format!("edit: cannot create {}", path)); return; } rsnix::gop::clear_screen(); rsdosedit::run(path); rsnix::gop::clear_screen(); } fn cmd_mem(p: &Parsed) { let (map, key) = rsnix::memory::get_map(); let mut total_kb = 0u64; let mut conventional_kb = 0u64; let mut loader_kb = 0u64; let mut runtime_kb = 0u64; let mut boot_code_kb = 0u64; let mut boot_data_kb = 0u64; let mut reserved_kb = 0u64; let mut acpi_kb = 0u64; let mut mmio_kb = 0u64; for desc in map { let pages = desc.number_of_pages; let bytes_4k = pages * 4; total_kb += bytes_4k; match desc.kind { 1 | 2 => loader_kb += bytes_4k, // LoaderCode + LoaderData 3 => boot_code_kb += bytes_4k, 4 => boot_data_kb += bytes_4k, 5 | 6 => runtime_kb += bytes_4k, // RuntimeCode + RuntimeData 7 => conventional_kb += bytes_4k, 9 | 10 => acpi_kb += bytes_4k, // ACPIReclaim + ACPINVS 11 | 12 => mmio_kb += bytes_4k, _ => reserved_kb += bytes_4k, // Reserved, Unusable, Pal, ... } } let h = rsnix::memory::heap_stats(); let ps = rsnix::memory::page_stats(); let mut pipe = Pipeline::new(&p.filters, &p.output); let _ = pipe.line(&alloc::format!( "memory map (key=0x{:x}, {} entries)", key, map.len() )); let _ = pipe.line(&alloc::format!( " usable {:4} MB Conventional", conventional_kb / 1024 )); let _ = pipe.line(&alloc::format!( " boot code {:4} MB BootServicesCode", boot_code_kb / 1024 )); let _ = pipe.line(&alloc::format!( " boot data {:4} MB BootServicesData", boot_data_kb / 1024 )); let _ = pipe.line(&alloc::format!( " loader {:4} MB LoaderCode + LoaderData", loader_kb / 1024 )); let _ = pipe.line(&alloc::format!( " runtime {:4} MB RuntimeServices", runtime_kb / 1024 )); let _ = pipe.line(&alloc::format!(" ACPI {:4} MB", acpi_kb / 1024)); let _ = pipe.line(&alloc::format!(" MMIO {:4} MB", mmio_kb / 1024)); let _ = pipe.line(&alloc::format!(" reserved {:4} MB", reserved_kb / 1024)); let _ = pipe.line(&alloc::format!( " raw total {:4} MB (incl. PCIe MMIO)", total_kb / 1024 )); let _ = pipe.line("heap:"); let _ = pipe.line(&alloc::format!( " range {:#x}..{:#x} {} KB total", h.heap_start, h.heap_end, h.heap_size / 1024 )); let _ = pipe.line(&alloc::format!( " free {} KB in {} block(s), allocated {} KB", h.free_bytes / 1024, h.free_blocks, h.heap_size.saturating_sub(h.free_bytes) / 1024 )); let _ = pipe.line("pages:"); let _ = pipe.line(&alloc::format!( " span base {:#x}, {} MB covered", ps.base_page * rsnix::memory::PAGE_SIZE, ps.span_bytes / 1024 / 1024 )); let _ = pipe.line(&alloc::format!( " free {} pages ({} MB), {} used", ps.free_pages, ps.free_pages * rsnix::memory::PAGE_SIZE / 1024 / 1024, ps.used_pages )); pipe.end(); } // --------------------------------------------------------------------------- // mmap — dump every entry of the UEFI memory map with type names // --------------------------------------------------------------------------- fn cmd_mmap(p: &Parsed) { let (map, key) = rsnix::memory::get_map(); let mut pipe = Pipeline::new(&p.filters, &p.output); let _ = pipe.line(&alloc::format!( "memory map (key=0x{:x}, {} entries):", key, map.len() )); let _ = pipe.line(" # type start pages size"); for (i, d) in map.iter().enumerate() { let l = alloc::format!( " {:>2} {:<16} {:#014x} {:>9} {:>8} KB", i, rsnix::uefi::memory_type_name(d.kind), d.physical_start, d.number_of_pages, d.number_of_pages * 4 ); if !pipe.line(&l) { break; } } pipe.end(); } // --------------------------------------------------------------------------- // stress — allocate blocks of KB, hold them, then free all. // Pushes the heap past its base region so growth into the page allocator // is exercised; also verifies a clean return to the free-list after. // --------------------------------------------------------------------------- fn cmd_stress(p: &Parsed) { let mut it = p.args.iter(); let chunks: usize = match it.next().and_then(|s| s.parse().ok()) { Some(n) if (1..=64).contains(&n) => n, _ => { term_err("usage: stress "); return; } }; let kb: usize = match it.next().and_then(|s| s.parse().ok()) { Some(n) if n > 0 && n <= 1024 * 1024 => n, _ => { term_err("usage: stress "); return; } }; let mut pipe = Pipeline::new(&p.filters, &p.output); let mut allocated = 0usize; { let mut blocks: [Option>; 64] = core::array::from_fn(|_| None); for i in 0..chunks { let mut b = alloc::vec::Vec::::new(); match b.try_reserve_exact(kb * 1024) { Ok(()) => { b.resize(kb * 1024, 0x42); blocks[i] = Some(b); allocated += 1; } Err(_) => { let _ = pipe.line(&alloc::format!(" block {}: out of memory, stopping", i)); break; } } } let h = rsnix::memory::heap_stats(); let ps = rsnix::memory::page_stats(); let _ = pipe.line(&alloc::format!( " held {} x {} KB = {} KB", allocated, kb, allocated * kb )); let _ = pipe.line(&alloc::format!( " heap free {} KB ({} blocks), pages free {} MB", h.free_bytes / 1024, h.free_blocks, ps.free_pages * 4 / 1024 )); // blocks dropped here → everything freed } let h = rsnix::memory::heap_stats(); let _ = pipe.line(&alloc::format!( " freed: heap free {} KB in {} blocks", h.free_bytes / 1024, h.free_blocks )); pipe.end(); } fn cmd_halt() -> ! { term_println("halting..."); rsnix::power::halt() } fn cmd_date(p: &Parsed) { let t = rsnix::rtc::now(); let mut pipe = Pipeline::new(&p.filters, &p.output); let line = alloc::format!( "{}-{:02}-{:02} {:02}:{:02}:{:02}", t.year, t.month, t.day, t.hour, t.minute, t.second ); let _ = pipe.line(&line); pipe.end(); } /// Deep device re-scan: AHCI ports → FAT volumes → VFS mount table. /// Returns (disks added, disks removed, vols mounted, vols unmounted, /// nodes added, nodes removed) so callers can report the result. fn rescan_all() -> (usize, usize, usize, usize, usize, usize) { let (da, dr) = rsnix::ahci::rescan(); let (ma, mu) = rsnix::fat::rescan(); let (na, nr) = rsnix::fs::rescan_disks(); (da, dr, ma, mu, na, nr) } /// Print the one-line change summary; returns true if anything changed. fn report_rescan((da, dr, ma, mu, na, nr): (usize, usize, usize, usize, usize, usize)) -> bool { term_println(&alloc::format!( "rescan: {} disk(s) added, {} removed · {} volume(s) mounted, {} unmounted · {} node(s) added, {} removed", da, dr, ma, mu, na, nr )); da + dr + ma + mu + na + nr > 0 } fn cmd_rescan(_p: &Parsed) { if report_rescan(rescan_all()) { term_println("rescan done — /disk updated"); } else { term_println("rescan done — nothing changed"); } } fn cmd_pci(p: &Parsed) { let devices = rsnix::pci::probe(); let mut pipe = Pipeline::new(&p.filters, &p.output); let _ = pipe.line(&alloc::format!("pci: {} device(s)", devices.len())); let _ = pipe.line(" bus dev fn class vendor:device"); for d in &devices { let l = alloc::format!( " {:02x}:{:02x}.{} {:<16} {:04x}:{:04x}", d.bus, d.device, d.func, rsnix::pci::class_name(d.class, d.subclass), d.vendor, d.device_id ); if !pipe.line(&l) { break; } } pipe.end(); } fn cmd_poweroff() -> ! { term_println("shutting down..."); rsnix::power::poweroff() }