diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 307acca..a980740 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -28,15 +28,35 @@ jobs: run: cargo test --workspace qemu: - name: Build boot image + name: Build and boot smoke test runs-on: ubuntu-latest needs: check steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly with: - components: llvm-tools-preview + components: llvm-tools-preview, rust-src - name: Install QEMU run: sudo apt-get update && sudo apt-get install -y qemu-system-x86 - name: Build boot image run: cargo build --verbose + - name: Boot BIOS image and assert serial success marker + shell: bash + run: | + set +e + timeout --signal=TERM --kill-after=5s 20s cargo run -- bios > qemu.log 2>&1 + status=$? + set -e + cat qemu.log + + if ! grep -Fq "[BOOT_OK] Rustalis booted successfully." qemu.log; then + echo "Kernel did not emit the expected boot-success marker." + exit 1 + fi + + # A healthy kernel intentionally remains in its idle loop; timeout + # is therefore expected after the marker has been observed. + if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then + echo "QEMU runner exited unexpectedly with status $status." + exit "$status" + fi diff --git a/README.md b/README.md index ec3c6a9..7a86423 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ On Windows PowerShell: cargo run -- uefi ``` +For common toolchain, QEMU, firmware, and boot troubleshooting, see [docs/troubleshooting.md](docs/troubleshooting.md). + ## Contributing Rustalis is intended to be a genuine open-source collaboration project. Start with the issue tracker before implementing a large subsystem. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..57f3f9e --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,57 @@ +# Troubleshooting Rustalis + +This guide separates host setup failures from kernel boot failures. Rustalis is experimental; a successful image build does not prove that the kernel boots correctly. + +## Prerequisites + +- Use the toolchain specified by `rust-toolchain.toml` (Rust nightly). +- Install Cargo and Git. +- Install QEMU with the `qemu-system-x86_64` executable available on `PATH`. +- For UEFI runs, the runner pins OVMF to EDK2 stable 202508-r1 through `Source::EDK2_STABLE202508_R1`. The `ovmf-prebuilt` crate validates the downloaded archive against its embedded SHA-256 and caches firmware under `target/ovmf`. + +Check the toolchain and QEMU executable: + +```sh +rustc --version +cargo --version +qemu-system-x86_64 --version +``` + +## Build or test failures + +Run the checks individually to identify the failing stage: + +```sh +cargo fmt --all -- --check +cargo check --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo build +``` + +- **Toolchain/target errors:** confirm Rustup selected the repository's toolchain and that the target named in the error is installed. +- **Dependency errors:** check network access and Cargo's registry cache, then retry the failing command. +- **Formatting errors:** run `cargo fmt --all` and inspect the resulting diff before committing. + +## Host runner failures + +Run either supported mode: + +```sh +cargo run -- uefi +cargo run -- bios +``` + +- **QEMU cannot start:** install the QEMU x86_64 system emulator and ensure `qemu-system-x86_64` is on `PATH`. +- **UEFI firmware acquisition fails:** check network access and write permissions for the repository's `target/ovmf` cache. BIOS mode does not require OVMF. +- **Invalid command:** use exactly one mode argument, `uefi` or `bios`; use `--help` for usage. + +## Kernel boot failures + +The QEMU runner configures serial output and disables the graphical display. Read the serial log first: + +- If no kernel log appears, investigate image creation, boot firmware, and QEMU configuration. +- If initialization logs appear but the final success message is absent, investigate the last subsystem log before the failure. +- A successful `cargo build` only verifies image construction. Treat runtime boot as verified only after the kernel actually runs and its expected serial output is observed. + +When reporting a failure, include the command, host OS, Rust toolchain version, QEMU version, and complete relevant serial/error output. Do not include secrets or private environment values. diff --git a/kernel/src/arch/x86_64/interrupts.rs b/kernel/src/arch/x86_64/interrupts.rs index b24d1ac..04d5059 100644 --- a/kernel/src/arch/x86_64/interrupts.rs +++ b/kernel/src/arch/x86_64/interrupts.rs @@ -14,5 +14,7 @@ pub struct InterruptStackFrame { pub fn init() { // SAFETY: maskable interrupts stay disabled until an IDT and handlers // have been installed. This prevents delivery to an incomplete table. - unsafe { asm!("cli", options(nomem, nostack, preserves_flags)); } + unsafe { + asm!("cli", options(nomem, nostack, preserves_flags)); + } } diff --git a/kernel/src/fs.rs b/kernel/src/fs.rs index f8e9448..3dbb3a6 100644 --- a/kernel/src/fs.rs +++ b/kernel/src/fs.rs @@ -3,8 +3,10 @@ //! This is a RAM-backed prototype, not persistent storage. It deliberately avoids //! allocation so it can be used before the kernel heap and block-device layer exist. -const MAX_PATH_BYTES: usize = 64; -const MAX_FILE_BYTES: usize = 256; +/// Maximum path length in bytes, including the leading slash. +pub const MAX_PATH_BYTES: usize = 64; +/// Maximum file content size in bytes. +pub const MAX_FILE_BYTES: usize = 256; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FsError { @@ -17,6 +19,9 @@ pub enum FsError { } /// A small, allocation-free filesystem suitable for kernel bring-up. +/// +/// Each slot stores a fixed-size path and content buffer. On x86_64, an entry +/// currently occupies approximately 344 bytes; exact layout is compiler-dependent. pub struct RamFs { entries: [Entry; FILES], } @@ -59,7 +64,10 @@ impl RamFs { if self.find(path).is_some() { return Err(FsError::AlreadyExists); } - let slot = self.entries.iter().position(|entry| !entry.used) + let slot = self + .entries + .iter() + .position(|entry| !entry.used) .ok_or(FsError::NoFreeSlots)?; let entry = &mut self.entries[slot]; entry.path[..path.len()].copy_from_slice(path.as_bytes()); @@ -100,8 +108,11 @@ impl RamFs { /// Return the path of the Nth occupied slot, for simple directory listings. pub fn path_at(&self, occupied_index: usize) -> Option<&str> { - self.entries.iter().filter(|entry| entry.used) - .nth(occupied_index).map(Entry::path) + self.entries + .iter() + .filter(|entry| entry.used) + .nth(occupied_index) + .map(Entry::path) } pub fn file_count(&self) -> usize { @@ -109,7 +120,9 @@ impl RamFs { } fn find(&self, path: &str) -> Option { - self.entries.iter().position(|entry| entry.used && entry.path() == path) + self.entries + .iter() + .position(|entry| entry.used && entry.path() == path) } } @@ -120,7 +133,10 @@ fn validate_path(path: &str) -> Result<&str, FsError> { if path.len() > MAX_PATH_BYTES { return Err(FsError::PathTooLong); } - if path.split('/').any(|part| part.is_empty() || part == "." || part == "..") { + if path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { return Err(FsError::InvalidPath); } Ok(path) @@ -128,7 +144,10 @@ fn validate_path(path: &str) -> Result<&str, FsError> { #[cfg(test)] mod tests { - use super::{FsError, RamFs}; + extern crate std; + + use super::{FsError, MAX_FILE_BYTES, MAX_PATH_BYTES, RamFs}; + use std::format; #[test] fn create_write_read_and_remove() { @@ -156,7 +175,57 @@ mod tests { fs.create("/a").unwrap(); assert_eq!(fs.create("/b"), Err(FsError::NoFreeSlots)); fs.write("/a", b"ok").unwrap(); - assert_eq!(fs.write("/a", &[0; 257]), Err(FsError::FileTooLarge)); + assert_eq!( + fs.write("/a", &[0; MAX_FILE_BYTES + 1]), + Err(FsError::FileTooLarge) + ); assert_eq!(fs.read("/a").unwrap(), b"ok"); } + + #[test] + fn handles_empty_files_and_content_replacement_boundaries() { + let mut fs = RamFs::<1>::new(); + fs.create("/data").unwrap(); + assert_eq!(fs.read("/data").unwrap(), b""); + + let maximum = [b'x'; MAX_FILE_BYTES]; + fs.write("/data", &maximum).unwrap(); + assert_eq!(fs.read("/data").unwrap(), &maximum); + + fs.write("/data", b"short").unwrap(); + assert_eq!(fs.read("/data").unwrap(), b"short"); + + assert_eq!( + fs.write("/data", &[b'z'; MAX_FILE_BYTES + 1]), + Err(FsError::FileTooLarge) + ); + assert_eq!(fs.read("/data").unwrap(), b"short"); + } + + #[test] + fn rejects_malformed_paths_without_consuming_slots() { + let mut fs = RamFs::<2>::new(); + fs.create("/keep").unwrap(); + + for path in ["relative", "/", "/trailing/", "/a//b", "/./a", "/a/../b"] { + assert_eq!(fs.create(path), Err(FsError::InvalidPath), "path: {path}"); + } + + assert_eq!(fs.file_count(), 1); + assert_eq!(fs.read("/keep").unwrap(), b""); + assert_eq!(fs.create("/b"), Ok(())); + assert_eq!(fs.file_count(), 2); + } + + #[test] + fn accepts_maximum_path_and_rejects_one_byte_over() { + let mut fs = RamFs::<2>::new(); + let maximum = format!("/{}", "a".repeat(MAX_PATH_BYTES - 1)); + let too_long = format!("/{}", "a".repeat(MAX_PATH_BYTES)); + + assert_eq!(maximum.len(), MAX_PATH_BYTES); + assert_eq!(fs.create(&maximum), Ok(())); + assert_eq!(fs.create(&too_long), Err(FsError::PathTooLong)); + assert_eq!(fs.file_count(), 1); + } } diff --git a/kernel/src/main.rs b/kernel/src/main.rs index 649d844..9827d45 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -1,7 +1,7 @@ #![no_std] #![no_main] -use bootloader_api::{entry_point, BootInfo}; +use bootloader_api::{BootInfo, entry_point}; use core::panic::PanicInfo; mod arch; @@ -34,10 +34,15 @@ fn kernel_main(boot_info: &'static mut BootInfo) -> ! { logger::info("RAM filesystem initialized; /welcome.txt verified"); logger::info_u64("RAM filesystem files", ramfs.file_count() as u64); } else { - logger::error("RAM filesystem smoke test failed"); + logger::error("RAM filesystem smoke test failed; boot success withheld"); + loop { + core::hint::spin_loop(); + } } - logger::info("Rustalis booted successfully."); + // This marker is a CI/debugging contract: emit it only after early init and + // the RAM filesystem smoke test have completed successfully. + logger::info("[BOOT_OK] Rustalis booted successfully."); loop { core::hint::spin_loop(); } diff --git a/kernel/src/memory/mod.rs b/kernel/src/memory/mod.rs index 06d5c92..e33903d 100644 --- a/kernel/src/memory/mod.rs +++ b/kernel/src/memory/mod.rs @@ -13,28 +13,82 @@ pub struct MemorySummary { } pub fn summarize(regions: &MemoryRegions) -> MemorySummary { - let mut usable_bytes = 0; - let mut reserved_bytes = 0; - - for region in regions.iter() { - let size = region.end.saturating_sub(region.start); - match region.kind { - MemoryRegionKind::Usable => usable_bytes = usable_bytes.saturating_add(size), - _ => reserved_bytes = reserved_bytes.saturating_add(size), + summarize_regions( + regions + .iter() + .map(|region| (region.kind, region.start, region.end)), + ) +} + +fn summarize_regions( + regions: impl IntoIterator, +) -> MemorySummary { + let mut summary = MemorySummary { + usable_bytes: 0, + reserved_bytes: 0, + region_count: 0, + }; + + for (kind, start, end) in regions { + let size = end.saturating_sub(start); + match kind { + MemoryRegionKind::Usable => { + summary.usable_bytes = summary.usable_bytes.saturating_add(size); + } + _ => { + summary.reserved_bytes = summary.reserved_bytes.saturating_add(size); + } } + summary.region_count += 1; } - MemorySummary { - usable_bytes, - reserved_bytes, - region_count: regions.iter().count(), - } + summary } #[cfg(test)] mod tests { + use super::{MemoryRegionKind, MemorySummary, summarize_regions}; + #[test] - fn saturating_arithmetic_is_safe_for_memory_counters() { - assert_eq!(u64::MAX.saturating_add(1), u64::MAX); + fn summarizes_usable_reserved_and_region_count() { + let summary = summarize_regions([ + (MemoryRegionKind::Usable, 0, 1024), + (MemoryRegionKind::Bootloader, 1024, 1536), + (MemoryRegionKind::Usable, 4096, 8192), + ]); + + assert_eq!( + summary, + MemorySummary { + usable_bytes: 5120, + reserved_bytes: 512, + region_count: 3, + } + ); + } + + #[test] + fn empty_memory_map_has_zero_totals() { + assert_eq!( + summarize_regions(core::iter::empty::<(MemoryRegionKind, u64, u64)>()), + MemorySummary { + usable_bytes: 0, + reserved_bytes: 0, + region_count: 0, + } + ); + } + + #[test] + fn invalid_ranges_and_overflow_are_handled_safely() { + let summary = summarize_regions([ + (MemoryRegionKind::Usable, 10, 5), + (MemoryRegionKind::Usable, 0, u64::MAX), + (MemoryRegionKind::Usable, 0, 1), + ]); + + assert_eq!(summary.usable_bytes, u64::MAX); + assert_eq!(summary.reserved_bytes, 0); + assert_eq!(summary.region_count, 3); } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 5135f42..8ba015f 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] channel = "nightly" -components = ["llvm-tools-preview", "rustfmt", "clippy"] +components = ["llvm-tools-preview", "rustfmt", "clippy", "rust-src"] targets = ["x86_64-unknown-none", "x86_64-unknown-uefi"] profile = "minimal" diff --git a/src/main.rs b/src/main.rs index ebc462f..96cb4e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,42 +1,130 @@ use ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}; use std::env; -use std::process::{exit, Command}; +use std::process::{Command, exit}; + +const USAGE: &str = "Usage: cargo run -- [uefi|bios]"; +// Pin the firmware release so a fresh checkout does not silently change boot firmware. +const OVMF_SOURCE: Source = Source::EDK2_STABLE202508_R1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BootMode { + Uefi, + Bios, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CliAction { + Run(BootMode), + Help, +} + +fn parse_args(args: &[String]) -> Result { + match args { + [] => Err("missing boot mode"), + [arg] if arg.eq_ignore_ascii_case("-h") || arg.eq_ignore_ascii_case("--help") => { + Ok(CliAction::Help) + } + [arg] if arg.eq_ignore_ascii_case("uefi") => Ok(CliAction::Run(BootMode::Uefi)), + [arg] if arg.eq_ignore_ascii_case("bios") => Ok(CliAction::Run(BootMode::Bios)), + [_] => Err("unknown boot mode; expected 'uefi' or 'bios'"), + _ => Err("expected exactly one argument"), + } +} fn main() { - let uefi_path = env!("UEFI_PATH"); - let bios_path = env!("BIOS_PATH"); - let args: Vec = env::args().collect(); - - let uefi = match args.get(1).map(|s| s.to_lowercase()).as_deref() { - Some("uefi") => true, - Some("bios") => false, - Some("-h") | Some("--help") => { - println!("Usage: cargo run -- [uefi|bios]"); - exit(0); + let args: Vec = env::args().skip(1).collect(); + let mode = match parse_args(&args) { + Ok(CliAction::Run(mode)) => mode, + Ok(CliAction::Help) => { + println!("{USAGE}"); + return; } - _ => { - eprintln!("Usage: cargo run -- [uefi|bios]"); - exit(1); + Err(message) => { + eprintln!("rustalis: {message}\n{USAGE}"); + exit(2); } }; + let uefi_path = env!("UEFI_PATH"); + let bios_path = env!("BIOS_PATH"); + let mut cmd = Command::new("qemu-system-x86_64"); cmd.args(["-serial", "mon:stdio", "-display", "none"]); - if uefi { - let prebuilt = Prebuilt::fetch(Source::LATEST, "target/ovmf") - .expect("failed to obtain OVMF firmware"); - let code = prebuilt.get_file(Arch::X64, FileType::Code); - let vars = prebuilt.get_file(Arch::X64, FileType::Vars); - cmd.arg("-drive").arg(format!("format=raw,file={uefi_path}")); - cmd.arg("-drive").arg(format!("if=pflash,format=raw,unit=0,file={},readonly=on", code.display())); - cmd.arg("-drive").arg(format!("if=pflash,format=raw,unit=1,file={},snapshot=on", vars.display())); - } else { - cmd.arg("-drive").arg(format!("format=raw,file={bios_path}")); + match mode { + BootMode::Uefi => { + let prebuilt = Prebuilt::fetch(OVMF_SOURCE, "target/ovmf").unwrap_or_else(|error| { + eprintln!( + "rustalis: failed to obtain OVMF firmware ({error:?}). Check network access or the cached firmware in target/ovmf." + ); + exit(1); + }); + let code = prebuilt.get_file(Arch::X64, FileType::Code); + let vars = prebuilt.get_file(Arch::X64, FileType::Vars); + cmd.arg("-drive") + .arg(format!("format=raw,file={uefi_path}")); + cmd.arg("-drive").arg(format!( + "if=pflash,format=raw,unit=0,file={},readonly=on", + code.display() + )); + cmd.arg("-drive").arg(format!( + "if=pflash,format=raw,unit=1,file={},snapshot=on", + vars.display() + )); + } + BootMode::Bios => { + cmd.arg("-drive") + .arg(format!("format=raw,file={bios_path}")); + } } - let status = cmd.status().expect("failed to start qemu-system-x86_64"); + let status = cmd.status().unwrap_or_else(|error| { + eprintln!( + "rustalis: failed to start qemu-system-x86_64: {error}. Install QEMU and ensure qemu-system-x86_64 is on PATH." + ); + exit(1); + }); if !status.success() { exit(status.code().unwrap_or(1)); } } + +#[cfg(test)] +mod tests { + use super::{BootMode, CliAction, parse_args}; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn accepts_bios_and_uefi_modes_case_insensitively() { + assert_eq!( + parse_args(&args(&["uefi"])), + Ok(CliAction::Run(BootMode::Uefi)) + ); + assert_eq!( + parse_args(&args(&["BIOS"])), + Ok(CliAction::Run(BootMode::Bios)) + ); + } + + #[test] + fn accepts_help_flags() { + assert_eq!(parse_args(&args(&["--help"])), Ok(CliAction::Help)); + assert_eq!(parse_args(&args(&["-h"])), Ok(CliAction::Help)); + } + + #[test] + fn rejects_missing_unknown_and_extra_arguments() { + assert_eq!(parse_args(&args(&[])), Err("missing boot mode")); + assert_eq!( + parse_args(&args(&["pxe"])), + Err("unknown boot mode; expected 'uefi' or 'bios'") + ); + assert_eq!( + parse_args(&args(&["uefi", "extra"])), + Err("expected exactly one argument") + ); + } +}