From 71b6445b048456bf198d8b622570db95f4665cb9 Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 21 Aug 2026 21:17:44 +0200 Subject: exec: PE32+ loader, versioned ABI, first external binaries (v0.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rsapi: the kernel<->binary ABI crate (no_std, no deps). Versioned Exports table (magic + version header), EntryPoint = extern "C" on x86_64-unknown-uefi (Win64: RCX/RDX/R8), Line/Arg/StreamResult types. Binaries link only this crate. rsnix::load: PE32+ parser — DOS/COFF/optional headers, section copy, BSS zero-fill, .reloc base relocations (DIR64) via rva_to_file mapping (the reloc directory is an RVA, not a file offset). Images are allocated at LOAD_BASE=192MiB inside the page allocator's conventional span and identity-mapped user+writable+nx. rsnix::exec: resolution name -> /disk//bin//.efi per mounted volume; spawn reserves a sched slot, stores a per-task ExecCtx (exports + argv block + image) before launch_at installs the frame — no adopt race. Trampoline calls entry(&Exports, argc, argv), posts TaskMsg::User(exit_code) to the shell. abort_current notifies the shell with u64::MAX when an exec'd binary dies, so a faulting command cannot leave the shell blocked forever. sched: reserve_slot/launch_at split out of spawn; task_exit made pub. shell: exec_external runs before non-frozen built-ins (ADR 0006 resolution rule); frozen core (help/clear/echo/halt/poweroff/exit/ rescan) is exempt. Exports v1 wires put_line to the terminal and get_line to EOF; stream injection lands in Phase 3. ^C while waiting kills the command task. bins/{hello,boom}: gate binaries. hello prints through the exports table; boom faults on 0x400000000000 to prove containment. The image entry point IS the ABI entry (efi_main carries the Exports signature). Version 0.6.0: exec exists end-to-end. Gates verified in QEMU: 'hello' loads from \\bin\\cli\\hello.efi, calls put_line across the ABI, exits 0, shell unblocks; 'boom' faults, task dies gracefully with full report, shell survives, jobs shows the corpse; built-in fallback works when the file is absent; test-disk PASS. --- Cargo.lock | 28 ++++- Cargo.toml | 4 +- bins/boom/Cargo.toml | 10 ++ bins/boom/src/main.rs | 31 ++++++ bins/hello/Cargo.toml | 10 ++ bins/hello/src/main.rs | 33 ++++++ rsapi/Cargo.toml | 7 ++ rsapi/src/lib.rs | 86 +++++++++++++++ rsnix/Cargo.toml | 3 + rsnix/src/exec.rs | 137 ++++++++++++++++++++++++ rsnix/src/lib.rs | 2 + rsnix/src/load.rs | 283 +++++++++++++++++++++++++++++++++++++++++++++++++ rsnix/src/sched.rs | 46 +++++++- shell/Cargo.toml | 1 + shell/src/lib.rs | 111 +++++++++++++++++++ 15 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 bins/boom/Cargo.toml create mode 100644 bins/boom/src/main.rs create mode 100644 bins/hello/Cargo.toml create mode 100644 bins/hello/src/main.rs create mode 100644 rsapi/Cargo.toml create mode 100644 rsapi/src/lib.rs create mode 100644 rsnix/src/exec.rs create mode 100644 rsnix/src/load.rs diff --git a/Cargo.lock b/Cargo.lock index 24b6a35..ed37b5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,9 +2,27 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "boom" +version = "0.6.0" +dependencies = [ + "rsapi", +] + +[[package]] +name = "hello" +version = "0.6.0" +dependencies = [ + "rsapi", +] + +[[package]] +name = "rsapi" +version = "0.6.0" + [[package]] name = "rsdos" -version = "0.5.11" +version = "0.6.0" dependencies = [ "rsnix", "shell", @@ -19,7 +37,10 @@ dependencies = [ [[package]] name = "rsnix" -version = "0.5.11" +version = "0.6.0" +dependencies = [ + "rsapi", +] [[package]] name = "rspaint" @@ -30,8 +51,9 @@ dependencies = [ [[package]] name = "shell" -version = "0.5.11" +version = "0.6.0" dependencies = [ + "rsapi", "rsdosedit", "rsnix", "rspaint", diff --git a/Cargo.toml b/Cargo.toml index 26e5874..e8e0827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] resolver = "2" -members = ["rsnix", "shell", "rsdos", "rsdosedit", "rspaint"] +members = ["rsnix", "shell", "rsdos", "rsdosedit", "rspaint", "rsapi", "bins/hello", "bins/boom"] [workspace.package] -version = "0.5.11" +version = "0.6.0" edition = "2024" # no_std UEFI kernel: no unwinder available, so panic must abort. diff --git a/bins/boom/Cargo.toml b/bins/boom/Cargo.toml new file mode 100644 index 0000000..2d31886 --- /dev/null +++ b/bins/boom/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "boom" +version.workspace = true +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +rsapi = { path = "../../rsapi" } diff --git a/bins/boom/src/main.rs b/bins/boom/src/main.rs new file mode 100644 index 0000000..0c3201b --- /dev/null +++ b/bins/boom/src/main.rs @@ -0,0 +1,31 @@ +//! boom — crash-containment probe (ADR 0006 Phase 2 gate). +//! +//! Deliberately faults on an unmapped address. The kernel must kill only +//! this task; the shell survives and `jobs` shows the corpse. + +#![no_std] +#![no_main] + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + loop { + core::hint::spin_loop(); + } +} + +/// The image entry point — the loader jumps here with the ABI arguments. +#[unsafe(no_mangle)] +pub extern "C" fn efi_main( + _exports: &'static rsapi::Exports, + _argc: usize, + _argv: *const rsapi::Arg, +) -> i32 { + // Canonical-but-unmapped address (below 2^47, above any RAM/MMIO): + // the page walk terminates at a not-present entry → #PF with + // CR2 = address. Same trick as the built-in `fault` command. + unsafe { + let p = 0x4000_0000_0000usize as *mut u64; + p.write_volatile(0xDEAD_BEEF); + } + 0 +} diff --git a/bins/hello/Cargo.toml b/bins/hello/Cargo.toml new file mode 100644 index 0000000..ac71ded --- /dev/null +++ b/bins/hello/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "hello" +version.workspace = true +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +rsapi = { path = "../../rsapi" } diff --git a/bins/hello/src/main.rs b/bins/hello/src/main.rs new file mode 100644 index 0000000..d1d58df --- /dev/null +++ b/bins/hello/src/main.rs @@ -0,0 +1,33 @@ +//! hello — the first rs-dos external binary (ADR 0006 Phase 2 gate). +//! +//! Prints one line through the injected Exports table and exits 0. +//! Exercises the whole chain: PE32+ load, relocation, ABI call, task exit. + +#![no_std] +#![no_main] + +use rsapi::Exports; + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + loop { + core::hint::spin_loop(); + } +} + +/// The image entry point — the loader jumps here with the ABI arguments +/// (exports table, argc, argv). The conventional UEFI name is kept so +/// the PE looks normal; the signature is the rs-dos ABI. +#[unsafe(no_mangle)] +pub extern "C" fn efi_main(exports: &'static Exports, _argc: usize, _argv: *const rsapi::Arg) -> i32 { + let msg = b"hello, world - from an external binary"; + let line = rsapi::Line { + ptr: msg.as_ptr(), + len: msg.len(), + }; + if (exports.put_line)(line) { + 0 + } else { + 1 + } +} diff --git a/rsapi/Cargo.toml b/rsapi/Cargo.toml new file mode 100644 index 0000000..fb43004 --- /dev/null +++ b/rsapi/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "rsapi" +version.workspace = true +edition.workspace = true + +[lints] +workspace = true diff --git a/rsapi/src/lib.rs b/rsapi/src/lib.rs new file mode 100644 index 0000000..f71e69f --- /dev/null +++ b/rsapi/src/lib.rs @@ -0,0 +1,86 @@ +//! rsapi — the kernel↔binary ABI (ADR 0006). +//! +//! One versioned `Exports` table is constructed per invocation and handed +//! to the binary's entry point. The header starts with a magic and a +//! `version: u32` so a future kernel can keep old binaries working +//! (dispatch on the version, never on struct layout). +//! +//! Everything here is `no_std`-clean: plain structs and function pointers, +//! no allocation, no rsnix dependency — binaries link only this crate. + +#![no_std] + +/// Table magic: "RSEX". +pub const EXPORTS_MAGIC: u32 = 0x5245_5853; + +/// ABI revision. Bump on any signature change; the kernel refuses to run +/// binaries whose required major exceeds its own. +pub const ABI_VERSION: u32 = 1; + +/// Entry-point signature. `extern "C"` on the x86_64-unknown-uefi +/// target is the Win64 convention: the three arguments arrive in +/// RCX/RDX/R8 on both sides of the table. +pub type EntryPoint = extern "C" fn(exports: &'static Exports, argc: usize, argv: *const Arg) -> i32; + +/// One C-style argv element: pointer + length (rs-dos strings are not +/// NUL-terminated in memory). +#[derive(Clone, Copy)] +pub struct Arg { + pub ptr: *const u8, + pub len: usize, +} + +/// A line of text handed across the ABI (stream I/O). +#[derive(Clone, Copy)] +pub struct Line { + pub ptr: *const u8, + pub len: usize, +} + +impl Line { + /// Borrow as a &str (lossy is impossible: lines are always UTF-8). + pub fn as_str(&self) -> &'static str { + unsafe { core::str::from_utf8_unchecked(core::slice::from_raw_parts(self.ptr, self.len)) } + } +} + +/// Outcome of `get_line`. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum StreamResult { + /// A line is available (see `out`). + Line, + /// Input exhausted. + Eof, + /// Aborted (^C or upstream killed); stop immediately. + Abort, +} + +// --------------------------------------------------------------------------- +// Exports table — v1 +// --------------------------------------------------------------------------- + +/// The function table a binary receives. Layout is fixed by `version`; +/// new functions are appended, existing ones never move. +#[repr(C)] +pub struct Exports { + // ---- header ---- + pub magic: u32, + pub version: u32, + + // ---- console / streams (v1) ---- + /// Print one line to the command's output stream. + /// Returns false when the consumer says stop (pager quit / head limit / + /// ^C) — the binary should wrap up promptly. + pub put_line: fn(line: Line) -> bool, + /// Read one line from the command's input stream (only meaningful for + /// input consumers like cat/hexdump; otherwise returns Eof). + /// `out` receives the line when the result is `StreamResult::Line`. + pub get_line: fn(out: &mut Line) -> StreamResult, + + // ---- lifecycle (v1) ---- + /// Log one line to the kernel log ring (/dev/klog). + pub log_line: fn(line: Line), + /// Exit code for the completion message to the spawner. + /// (Calling this is optional; returning from the entry point exits 0.) + pub exit: fn(code: i32) -> !, +} diff --git a/rsnix/Cargo.toml b/rsnix/Cargo.toml index 5225b1a..f0dbad1 100644 --- a/rsnix/Cargo.toml +++ b/rsnix/Cargo.toml @@ -5,3 +5,6 @@ edition.workspace = true [lints] workspace = true + +[dependencies] +rsapi = { path = "../rsapi" } diff --git a/rsnix/src/exec.rs b/rsnix/src/exec.rs new file mode 100644 index 0000000..7ba39aa --- /dev/null +++ b/rsnix/src/exec.rs @@ -0,0 +1,137 @@ +// --------------------------------------------------------------------------- +// exec — run an external binary (ADR 0006 Phase 2). +// +// Resolution: name → \bin\\ on each mounted volume → None. +// The shell calls exec::spawn with a fully constructed Exports table +// (streams point at its pipeline/tty) and clean argv. The binary runs as +// its own task; on exit it posts TaskMsg::User(exit_code) to the spawner. +// +// The Exports table and argv block are heap boxes moved into static slots +// indexed by task id — the trampoline adopts them before jumping to the +// image entry. One slot per MAX_TASKS; spawn fails if the slot is busy. +// --------------------------------------------------------------------------- +use crate::load; +use crate::sched::{self, TaskMsg}; +use alloc::string::String; +use alloc::vec::Vec; +use rsapi::{Arg, EntryPoint, Exports}; + +/// Per-task invocation context adopted by the trampoline. +struct ExecCtx { + exports: &'static Exports, + /// Heap block holding the argv strings (kept alive until task exit — + /// the Arg pointers reference into it). + #[allow(dead_code)] + argv_buf: Vec, + args: Vec, + /// Image pages to free when the task dies. + image: load::Image, +} + +static mut CTX: [Option; sched::MAX_TASKS] = [const { None }; sched::MAX_TASKS]; + +/// Error of exec::resolve / exec::spawn. +pub enum ExecError { + NotFound, + LoadFailed(&'static str), + Busy, +} + +/// Scan every mounted volume for `\bin\\` (LFN exact match). +/// Returns the VFS handle of the first hit. +pub fn resolve(kind: &str, name: &str) -> Option { + for vol in 0..crate::fat::volume_count() { + if !crate::fat::is_mounted(vol) { + continue; + } + let uid = String::from(crate::fat::uid(vol)); + let path = alloc::format!("/disk/{}/bin/{}/{}.efi", uid, kind, name); + if let Some(h) = crate::fs::resolve(&path) { + return Some(h); + } + } + None +} + +/// Read the whole file into a heap buffer (public: the shell uses this +/// to fetch binary bytes before calling load::load_image). +#[allow(dead_code)] +pub fn read_file(h: usize) -> Result, ()> { + let size = crate::fs::size(h) as usize; + let mut buf = Vec::with_capacity(size); + let mut chunk = [0u8; 512]; + let mut pos = 0u64; + loop { + let n = crate::fs::read(h, &mut chunk, pos)?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + pos += n as u64; + } + Ok(buf) +} + +/// Spawn `image` as a new task running entry(exports, argc, argv). +/// +/// The caller keeps `exports` alive (it is constructed per invocation in +/// shell-owned memory that outlives the command — the pipeline/tty live +/// in the blocked spawner). +pub fn spawn( + image: load::Image, + exports: &'static Exports, + argv: &[String], + name: &str, +) -> Result { + // Copy the argv strings into one contiguous block owned by the task. + let mut argv_buf: Vec = Vec::new(); + let mut args: Vec = Vec::with_capacity(argv.len()); + for a in argv { + let off = argv_buf.len(); + argv_buf.extend_from_slice(a.as_bytes()); + args.push(Arg { + ptr: unsafe { argv_buf.as_ptr().add(off) }, + len: a.len(), + }); + } + + // Reserve a task slot first so the context is in place before the + // task can possibly run (spawn may schedule immediately). + let id = sched::reserve_slot().ok_or(ExecError::Busy)?; + unsafe { + CTX[id] = Some(ExecCtx { + exports, + argv_buf, + args, + image, + }); + } + if !sched::launch_at(id, trampoline, name) { + unsafe { + CTX[id] = None; + } + return Err(ExecError::Busy); + } + Ok(id) +} + +/// Trampoline: adopt the exec context, call the entry, report completion. +fn trampoline() { + let id = sched::current(); + let taken = unsafe { + let ctx_ptr = core::ptr::addr_of_mut!(CTX); + (*ctx_ptr)[id].take() + }; + let Some(ctx) = taken else { + sched::abort_current("exec: no context") + }; + // Call the image entry with the table; the return value is the exit code. + let code = unsafe { + let f: EntryPoint = core::mem::transmute(ctx.image.entry); + f(ctx.exports, ctx.args.len(), ctx.args.as_ptr()) + }; + // Report completion to the spawner (the shell), then die. The spawner + // frees the image pages after reaping the message. + let _ = sched::send(sched::SHELL_TID, TaskMsg::User(code as u64)); + drop(ctx); +} diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs index a422057..376d3b5 100644 --- a/rsnix/src/lib.rs +++ b/rsnix/src/lib.rs @@ -12,6 +12,8 @@ pub mod gdt; pub mod gop; pub mod interrupts; pub mod klog; +pub mod exec; +pub mod load; pub mod memory; pub mod paging; pub mod pci; diff --git a/rsnix/src/load.rs b/rsnix/src/load.rs new file mode 100644 index 0000000..cebca89 --- /dev/null +++ b/rsnix/src/load.rs @@ -0,0 +1,283 @@ +// --------------------------------------------------------------------------- +// load — PE32+ executable loader (ADR 0006 Phase 2). +// +// Parses a PE32+ image read through the VFS, allocates identity-mapped +// pages for it (the kernel runs identity-mapped, so "user" code lives at +// its link address), applies base relocations, and returns the entry +// point. The spawn path (exec.rs glue in sched) builds an Exports table, +// copies the argv block into the image's memory, and starts a task whose +// trampoline calls entry(&Exports, argc, argv). +// +// Scope: what rustc/lld emits for a no_std UEFI-ish binary — DOS header, +// COFF header, optional header PE32+, section table, .reloc base +// relocations. No imports, no TLS, no exceptions. +// --------------------------------------------------------------------------- +use crate::paging; + +/// Where binaries are loaded: above the heap, inside conventional RAM. +/// The page allocator's span covers the conventional-memory range of the +/// boot memory map; LOAD_BASE must sit inside it (heap ends ~0xbb6c000 +/// with 256 MiB guests; conventional RAM typically ends near 0x0FF00000). +const LOAD_BASE: usize = 0x0C00_0000; // 192 MiB +const LOAD_LIMIT: usize = 0x0F00_0000; // stop before top-of-RAM + +const PAGE_SIZE: usize = 4096; + +/// A loaded image: pages to free on exit + the entry point. +pub struct Image { + /// First allocated page (free this..+page_count on exit). + pub base: usize, + pub page_count: usize, + pub entry: usize, +} + +#[derive(Debug)] +pub enum LoadError { + ReadFailed, + NotPe, + NotPe32Plus, + BadSectionTable, + NoEntryPoint, + AllocFailed, + MapFailed, + BadReloc, + TooBig, +} + +// --------------------------------------------------------------------------- +// PE file-format structures (packed reads via byte slices — no unaligned +// struct pointer derefs). +// --------------------------------------------------------------------------- + +fn u16_at(b: &[u8], off: usize) -> Option { + b.get(off..off + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) +} +fn u32_at(b: &[u8], off: usize) -> Option { + b.get(off..off + 4) + .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} +fn u64_at(b: &[u8], off: usize) -> Option { + b.get(off..off + 8).map(|s| { + let mut t = [0u8; 8]; + t.copy_from_slice(s); + u64::from_le_bytes(t) + }) +} + +const DOS_MAGIC: u16 = 0x5A4D; // "MZ" +const PE_MAGIC: u32 = 0x0000_4550; // "PE\0\0" +const OPT_PE32PLUS: u16 = 0x20B; +const DIR_BASERELOC: usize = 5; + +/// Map an RVA to its raw file offset via the section table. +fn rva_to_file(sections: &[Option
], count: usize, rva: usize) -> Option { + for s in sections.iter().take(count).flatten() { + if rva >= s.virt_addr && rva < s.virt_addr + s.raw_size.max(s.virt_size) { + return Some(s.raw_ptr + (rva - s.virt_addr)); + } + } + None +} + +#[derive(Clone, Copy)] +struct Section { + virt_addr: usize, + virt_size: usize, + raw_ptr: usize, + raw_size: usize, +} + +/// Parse headers, apply relocations into `mem`, return the entry RVA. +fn parse_and_relocate(img: &[u8], mem: usize) -> Result { + // ---- DOS header ---- + if u16_at(img, 0) != Some(DOS_MAGIC) { + return Err(LoadError::NotPe); + } + let e_lfanew = u32_at(img, 0x3C).ok_or(LoadError::NotPe)? as usize; + + // ---- COFF header ---- + if u32_at(img, e_lfanew) != Some(PE_MAGIC) { + return Err(LoadError::NotPe); + } + let coff = e_lfanew + 4; + let num_sections = u16_at(img, coff + 2).ok_or(LoadError::NotPe)? as usize; + let opt_size = u16_at(img, coff + 16).ok_or(LoadError::NotPe)? as usize; + if opt_size == 0 { + return Err(LoadError::NotPe); + } + + // ---- Optional header (PE32+) ---- + let opt = coff + 20; + if u16_at(img, opt) != Some(OPT_PE32PLUS) { + return Err(LoadError::NotPe32Plus); + } + let entry_rva = u32_at(img, opt + 16).ok_or(LoadError::NotPe32Plus)? as usize; + let image_base = u64_at(img, opt + 24).ok_or(LoadError::NotPe32Plus)? as usize; + + let num_dirs = u32_at(img, opt + 108).ok_or(LoadError::NotPe32Plus)? as usize; + let dir_base = opt + 112; + let (reloc_rva, reloc_size) = if num_dirs > DIR_BASERELOC { + ( + u32_at(img, dir_base + DIR_BASERELOC * 8).ok_or(LoadError::BadReloc)? as usize, + u32_at(img, dir_base + DIR_BASERELOC * 8 + 4).ok_or(LoadError::BadReloc)? as usize, + ) + } else { + (0, 0) + }; + + // ---- Section table ---- + let sect_tab = opt + opt_size; + let mut sections: [Option
; 96] = [None; 96]; + if num_sections > sections.len() { + return Err(LoadError::BadSectionTable); + } + for i in 0..num_sections { + let s = sect_tab + i * 40; + let virt_size = u32_at(img, s + 8).ok_or(LoadError::BadSectionTable)? as usize; + let virt_addr = u32_at(img, s + 12).ok_or(LoadError::BadSectionTable)? as usize; + let raw_size = u32_at(img, s + 16).ok_or(LoadError::BadSectionTable)? as usize; + let raw_ptr = u32_at(img, s + 20).ok_or(LoadError::BadSectionTable)? as usize; + sections[i] = Some(Section { + virt_addr, + virt_size, + raw_ptr, + raw_size, + }); + } + + // ---- Copy section contents ---- + for s in sections.iter_mut().take(num_sections).flatten() { + let n = s.raw_size.min(s.virt_size); + if n == 0 { + continue; + } + let src = img.get(s.raw_ptr..s.raw_ptr + n).ok_or(LoadError::BadSectionTable)?; + unsafe { + let dst = (mem + s.virt_addr) as *mut u8; + core::ptr::copy_nonoverlapping(src.as_ptr(), dst, n); + } + } + + // ---- Apply base relocations ---- + if reloc_size > 0 && reloc_rva != 0 { + // The .reloc directory is an RVA; the section table maps it to a + // raw file offset. + let mut off = rva_to_file(§ions, num_sections, reloc_rva).ok_or(LoadError::BadReloc)?; + let end = off + reloc_size; + while off + 8 <= end { + let page_rva = u32_at(img, off).ok_or(LoadError::BadReloc)? as usize; + let block_size = u32_at(img, off + 4).ok_or(LoadError::BadReloc)? as usize; + if block_size < 8 || off + block_size > end { + break; + } + let entries = (block_size - 8) / 2; + for i in 0..entries { + let e = u16_at(img, off + 8 + i * 2).ok_or(LoadError::BadReloc)?; + let r#type = e >> 12; + let pos = e & 0xFFF; + if r#type == 0 { + continue; // IMAGE_REL_BASED_ABSOLUTE — padding + } + let target = mem + page_rva + pos as usize; + match r#type { + 10 => { + // DIR64: 8-bit image base delta + unsafe { + let p = target as *mut u64; + let v = p.read_unaligned(); + p.write_unaligned(v.wrapping_add((mem as u64).wrapping_sub(image_base as u64))); + } + } + _ => return Err(LoadError::BadReloc), + } + } + off += block_size; + } + } + + Ok(entry_rva) +} + +/// Load a PE32+ image from the given bytes. Returns the loaded image +/// (identity-mapped at LOAD_BASE+) with its entry point resolved. +pub fn load_image(bytes: &[u8]) -> Result { + if bytes.len() > 8 * 1024 * 1024 { + return Err(LoadError::TooBig); + } + // Quick header sanity before allocating. + if u16_at(bytes, 0) != Some(DOS_MAGIC) { + return Err(LoadError::NotPe); + } + let e_lfanew = u32_at(bytes, 0x3C).unwrap_or(0) as usize; + if u32_at(bytes, e_lfanew) != Some(PE_MAGIC) { + return Err(LoadError::NotPe); + } + + let page_count = bytes.len().div_ceil(PAGE_SIZE); + // Allocate physical pages at a fixed window (identity mapping makes + // the link address = runtime address after relocation). + let base = crate::memory::alloc_pages_above(page_count, LOAD_BASE).ok_or(LoadError::AllocFailed)?; + if base >= LOAD_LIMIT { + crate::memory::free_pages(base, page_count); + return Err(LoadError::TooBig); + } + // Identity-map them user+writable+nx-default (relocations need write; + // we do not enforce W^X here — single-privilege-level kernel). + if !paging::map_range(base, page_count, paging::PAGE_USER | paging::PAGE_WRITABLE | paging::PAGE_NX) { + crate::memory::free_pages(base, page_count); + return Err(LoadError::MapFailed); + } + + let entry_rva = match parse_and_relocate(bytes, base) { + Ok(e) => e, + Err(e) => { + crate::memory::free_pages(base, page_count); + return Err(e); + } + }; + // Clear BSS tails: sections were copied raw-size bytes each; anything + // between raw_size and virt_size is zero per PE spec. parse_and_relocate + // only copied min(raw,virt); zero-fill the remainder now. + zero_bss_tails(bytes, base); + + Ok(Image { + base, + page_count, + entry: base + entry_rva, + }) +} + +/// Zero the part of each section between raw_size and virt_size (BSS). +fn zero_bss_tails(img: &[u8], mem: usize) { + // Re-walk the section table minimally (headers are small). + let Some(e_lfanew) = u32_at(img, 0x3C) else { return }; + let coff = e_lfanew as usize + 4; + let Some(num_sections) = u16_at(img, coff + 2) else { return }; + let Some(opt_size) = u16_at(img, coff + 16) else { return }; + let sect_tab = coff + 20 + opt_size as usize; + for i in 0..num_sections as usize { + let Some(virt_size) = u32_at(img, sect_tab + i * 40 + 8) else { return }; + let Some(raw_size) = u32_at(img, sect_tab + i * 40 + 16) else { return }; + let Some(virt_addr) = u32_at(img, sect_tab + i * 40 + 12) else { return }; + if virt_size > raw_size { + unsafe { + core::ptr::write_bytes( + (mem + virt_addr as usize + raw_size as usize) as *mut u8, + 0, + virt_size as usize - raw_size as usize, + ); + } + } + } +} + +/// Free a previously loaded image. +/// +/// The task that ran the image must be dead — no code may still execute +/// from or reference these pages. +pub unsafe fn unload_image(img: &Image) { + for i in 0..img.page_count { + let _ = paging::unmap_page(img.base + i * PAGE_SIZE); + } + crate::memory::free_pages(img.base, img.page_count); +} diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs index 0c4938e..107dba6 100644 --- a/rsnix/src/sched.rs +++ b/rsnix/src/sched.rs @@ -396,6 +396,31 @@ pub fn spawn(entry: fn(), name: &str) -> Option { if ok { Some(id) } else { None } } +/// Reserve an empty task slot without installing a frame. The slot is +/// marked Ready-with-no-frame is NOT valid to schedule — callers must +/// follow with `launch_at` before yielding (exec does both back-to-back +/// under the preemption lock window of the caller). +pub fn reserve_slot() -> Option { + lock(); + let id = unsafe { + let mut slot = None; + for i in 2..MAX_TASKS { + if TASKS[i].state == TaskState::Done && TASKS[i].stack != 0 { + reap(i); + slot = Some(i); + break; + } + if TASKS[i].state == TaskState::Done && TASKS[i].stack == 0 && TASKS[i].name_len == 0 { + slot = Some(i); + break; + } + } + slot + }; + unlock(); + id +} + unsafe fn reap(id: usize) { unsafe { let t = &mut TASKS[id]; @@ -408,6 +433,19 @@ unsafe fn reap(id: usize) { } } +/// Install the initial frame for a reserved slot and mark it Ready. +/// The slot must have come from `reserve_slot` and must not have been +/// scheduled in between (exec stores its context between the two calls). +pub fn launch_at(id: usize, entry: fn(), name: &str) -> bool { + if id >= MAX_TASKS { + return false; + } + lock(); + let ok = spawn_at(id, entry, name); + unlock(); + ok +} + fn spawn_at(id: usize, entry: fn(), name: &str) -> bool { let Some(base) = memory::alloc_pages(STACK_PAGES) else { return false; @@ -483,7 +521,7 @@ unsafe extern "C" fn trampoline(entry: usize) -> ! { task_exit() } -fn task_exit() -> ! { +pub fn task_exit() -> ! { lock(); unsafe { TASKS[CURRENT].state = TaskState::Done; @@ -648,6 +686,12 @@ pub fn abort_current(msg: &str) -> ! { TASKS[id].stack, TASKS[id].guard ); + // An exec'd binary died: unblock the waiting spawner with a + // completion message (exit code -1 encoded as u64::MAX). The + // shell's queue is the only waiter; best-effort by design. + if id >= 2 { + let _ = push_msg(SHELL_TID, TaskMsg::User(u64::MAX)); + } PREEMPT_DEPTH.store(0, Ordering::Relaxed); } yield_int(); diff --git a/shell/Cargo.toml b/shell/Cargo.toml index f95b574..b59dfec 100644 --- a/shell/Cargo.toml +++ b/shell/Cargo.toml @@ -8,5 +8,6 @@ workspace = true [dependencies] rsnix = { path = "../rsnix" } +rsapi = { path = "../rsapi" } rsdosedit = { path = "../rsdosedit" } rspaint = { path = "../rspaint" } diff --git a/shell/src/lib.rs b/shell/src/lib.rs index d1710cc..9ca999e 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -745,6 +745,15 @@ write `{} TIL `", } } + // Resolution rule (ADR 0006): \bin\\ first, else the + // built-in. The frozen core (help/clear/echo/poweroff/exit/halt/ + // rescan) is exempt — it exists to fix the system and never migrates. + if !matches!(cmd, "help" | "clear" | "echo" | "halt" | "poweroff" | "exit" | "rescan") + && exec_external(&parsed) + { + return; + } + match cmd { "help" => cmd_help(&parsed), "echo" => cmd_echo(&parsed), @@ -791,6 +800,108 @@ write `{} TIL `", } } +// --------------------------------------------------------------------------- +// External binaries (ADR 0006 Phase 2) +// +// Resolution: \bin\cli\ on each mounted volume; else "unknown +// command". The Exports table is constructed per invocation: v1 wires +// put_line to the active terminal and get_line to EOF (stream injection +// through the filter pipeline lands in Phase 3). +// --------------------------------------------------------------------------- + +static mut LAST_EXIT_CODE: i32 = 0; + +/// Last exit code of an external command ($? equivalent for scripts). +pub fn last_exit_code() -> i32 { + unsafe { core::ptr::addr_of!(LAST_EXIT_CODE).read() } +} + +/// ABI callback: print one line to the invoking terminal. +fn abi_put_line(line: rsapi::Line) -> bool { + term_println(line.as_str()); + true +} + +/// ABI callback: read one input line — no stream source wired yet (Phase 3). +fn abi_get_line(_out: &mut rsapi::Line) -> rsapi::StreamResult { + rsapi::StreamResult::Eof +} + +/// ABI callback: kernel log. +fn abi_log_line(line: rsapi::Line) { + rsnix::klog::append(line.as_str(), 0); +} + +/// ABI callback: exit with a code. +fn abi_exit(code: i32) -> ! { + unsafe { + LAST_EXIT_CODE = code; + } + // Unwind the binary by longjmp-style task abort: the trampoline never + // returns here. We report the code via the static and kill the task. + rsnix::sched::abort_current("exit") +} + +static EXPORTS_V1: rsapi::Exports = rsapi::Exports { + magic: rsapi::EXPORTS_MAGIC, + version: rsapi::ABI_VERSION, + put_line: abi_put_line, + get_line: abi_get_line, + log_line: abi_log_line, + exit: abi_exit, +}; + +/// Run `cmd` as an external PE32+ binary from /bin/cli/. Returns false +/// when no external binary resolved (the caller falls back to built-ins). +fn exec_external(p: &Parsed) -> bool { + let Some(h) = rsnix::exec::resolve("cli", &p.cmd) else { + return false; + }; + let bytes = match rsnix::exec::read_file(h) { + Ok(b) => b, + Err(_) => { + term_err(&alloc::format!("{}: read failed", p.cmd)); + return true; + } + }; + let image = match rsnix::load::load_image(&bytes) { + Ok(img) => img, + Err(e) => { + term_err(&alloc::format!("{}: cannot load ({:?})", p.cmd, e)); + return true; + } + }; + let mut argv: Vec = Vec::with_capacity(p.args.len() + 1); + argv.push(p.cmd.clone()); + argv.extend(p.args.iter().cloned()); + let name = alloc::format!("{}", p.cmd); + match rsnix::exec::spawn(image, &EXPORTS_V1, &argv, &name) { + Ok(id) => { + // Wait for the completion message (TaskMsg::User(exit_code)). + loop { + match rsnix::sched::recv_blocking() { + rsnix::sched::TaskMsg::User(code) => { + unsafe { + LAST_EXIT_CODE = code as i32; + } + break; + } + rsnix::sched::TaskMsg::Key(rsnix::ps2::KeyEvent::Ctrl('c')) => { + // ^C while waiting: kill the command task. + let _ = rsnix::sched::kill(id); + term_err("^C"); + break; + } + _ => {} + } + } + } + Err(rsnix::exec::ExecError::Busy) => term_err(&alloc::format!("{}: task table full", p.cmd)), + Err(_) => term_err(&alloc::format!("{}: cannot start", p.cmd)), + } + true +} + // --------------------------------------------------------------------------- // Built-in commands // --------------------------------------------------------------------------- -- cgit v1.2.3