11 Commits

14 changed files with 446 additions and 22 deletions

2
Cargo.lock generated
View File

@@ -61,7 +61,7 @@ dependencies = [
[[package]]
name = "edera-sprout"
version = "0.0.4"
version = "0.0.8"
dependencies = [
"anyhow",
"image",

View File

@@ -2,7 +2,7 @@
name = "edera-sprout"
description = "Modern UEFI bootloader"
license = "Apache-2.0"
version = "0.0.4"
version = "0.0.8"
homepage = "https://sprout.edera.dev"
repository = "https://github.com/edera-dev/sprout"
edition = "2024"

View File

@@ -18,9 +18,28 @@ existing UEFI bootloader or booted by the hardware directly.
Sprout is licensed under Apache 2.0 and is open to modifications and contributions.
## Background
At [Edera] we make compute isolation technology for a wide variety of environments, often ones we do not fully control.
Our technology utilizes a hypervisor to boot the host system to provide a new isolation mechanism that works
with or without hardware virtualization support. To do this we need to inject our hypervisor at boot time.
Unfortunately, GRUB, the most common bootloader on Linux systems today, utilizes a shell-script like
configuration system. Both the code that runs to generate a GRUB config and the GRUB config
itself is fully turing complete. This makes modifying boot configuration difficult and error-prone.
Sprout was designed to take in a machine-readable, writable, and modifiable configuration that treats boot information
like data plus configuration, and can be chained from both UEFI firmware and GRUB alike.
Sprout aims to be flexible, secure, and modern. Written in Rust, it handles data safely and uses unsafe code as little
as possible. It also critically must be easy to install into all common distributions, relying on simple principles to
simplify installation and usage.
## Documentation
- [Fedora Setup Guide]
- [Generic Linux Setup Guide]
- [Windows Setup Guide]
- [Development Guide]
- [Contributing Guide]
- [Sprout License]
@@ -38,6 +57,7 @@ have secure boot support. In fact, as of writing, it doesn't even have a boot me
- [x] [Bootloader specification (BLS)](https://uapi-group.org/specifications/specs/boot_loader_specification/) support
- [x] Chainload support
- [x] Linux boot support via EFI stub
- [x] Windows boot support via chainload
- [x] Load Linux initrd from disk
- [x] Boot first configured entry
@@ -46,7 +66,6 @@ have secure boot support. In fact, as of writing, it doesn't even have a boot me
- [ ] Boot menu
- [ ] Secure Boot support: work in progress
- [ ] UKI support: partial
- [ ] Windows boot support (untested via chainload)
- [ ] multiboot2 support
- [ ] Linux boot protocol (boot without EFI stub)
@@ -71,6 +90,17 @@ See [Configuration](#configuration) for how to configure sprout.
Sprout is configured using a TOML file at `\sprout.toml` on the root of the EFI partition sprout was booted from.
### Command Line Options
Sprout supports some command line options that can be combined to modify behavior without the configuration file.
```bash
# Boot Sprout with a specific configuration file.
$ sprout.efi --config=\path\to\config.toml
# Boot a specific entry, bypassing the menu.
$ sprout.efi --boot="Boot Xen"
```
### Boot Linux from ESP
```toml
@@ -126,7 +156,10 @@ chainload.options = ["$options"]
chainload.linux-initrd = "$boot\\$initrd"
```
[Edera]: https://edera.dev
[Fedora Setup Guide]: ./docs/fedora-setup.md
[Generic Linux Setup Guide]: ./docs/generic-linux-setup.md
[Windows Setup Guide]: ./docs/windows-setup.md
[Development Guide]: ./DEVELOPMENT.md
[Contributing Guide]: ./CONTRIBUTING.md
[Sprout License]: ./LICENSE

View File

@@ -1,4 +1,4 @@
# Sprout on Fedora
# Setup Sprout on Fedora
## Prerequisites

View File

@@ -0,0 +1,62 @@
# Setup Sprout to boot Linux
## Prerequisites
- EFI System Partition mounted on a known path
- Linux kernel installed with an optional initramfs
- Linux kernel must support the EFI stub (most distro kernels)
## Step 1: Base Installation
First, identify the path to your EFI System Partition. On most systems, this is `/boot/efi`.
Download the latest sprout.efi release from the [GitHub releases page](https://github.com/edera-dev/sprout/releases).
For x86_64 systems, download the `sprout-x86_64.efi` file, and for ARM systems, download the `sprout-aarch64.efi` file.
Copy the downloaded `sprout.efi` file to `/EFI/BOOT/sprout.efi` on your EFI System Partition.
## Step 2: Copy kernel and optional initramfs
Copy the Linux kernel to `/vmlinuz-sprout` on your EFI System Partition.
If needed, copy the initramfs to `/initramfs-sprout` on your EFI System Partition.
## Step 3: Configure Sprout
Write the following file to `/sprout.toml` on your EFI System Partition,
paying attention to place the correct values:
```toml
# sprout configuration: version 1
version = 1
# add a boot entry for booting linux
# which will run the boot-linux action.
[entries.boot-linux]
title = "Boot Linux"
actions = ["boot-linux"]
# use the chainload action to boot linux via the efi stub.
# the options below are passed to the efi stub as the
# kernel command line. the initrd is loaded using the efi stub
# initrd loader mechanism.
[actions.boot-linux]
chainload.path = "\\vmlinuz-sprout"
chainload.options = ["root=/dev/sda1", "my-kernel-option"]
chainload.linux-initrd = "\\initramfs-sprout"
```
You can specify any kernel command line options you want on the chainload options line.
They will be concatenated by a space and passed to the kernel.
## Step 4: Configure EFI firmware to boot Sprout
Since Sprout is still experimental, the following commands will add a boot entry to your EFI firmware for sprout but
intentionally do not set it as the default boot entry.
To add the entry, please find the partition device of your EFI System Partition and run the following:
```bash
$ sudo efibootmgr -d /dev/esp_partition_here -C -L 'Sprout' -l '\EFI\BOOT\sprout.efi'
```
This will add a new entry to your EFI boot menu called `Sprout` that will boot Sprout with your configuration.
Now if you boot into your UEFI firmware, you should see Sprout as an option to boot.

52
docs/windows-setup.md Normal file
View File

@@ -0,0 +1,52 @@
# Setup Sprout to boot Windows
## Prerequisites
- Secure Boot disabled
- UEFI Windows installation
## Step 1: Base Installation
First, mount the EFI System Partition on your Windows installation:
In an administrator command prompt, run:
```batch
> mountvol X: /s
```
This will mount the EFI System Partition to the drive letter `X:`.
Please note that Windows Explorer will not let you see the drive letter `X:` where the ESP is mounted.
You will need to use the command prompt or PowerShell to access the ESP.
Standard editors can, however, be used to edit files on the ESP.
Download the latest sprout.efi release from the [GitHub releases page](https://github.com/edera-dev/sprout/releases).
For x86_64 systems, download the `sprout-x86_64.efi` file, and for ARM systems, download the `sprout-aarch64.efi` file.
Copy the downloaded `sprout.efi` file to `X:\EFI\BOOT\sprout.efi` on your EFI System Partition.
## Step 3: Configure Sprout
Write the following file to `X:\sprout.toml`:
```toml
# sprout configuration: version 1
version = 1
# add a boot entry for booting Windows
# which will run the boot-windows action.
[entries.windows]
title = "Windows"
actions = ["boot-windows"]
# use the chainload action to boot the Windows bootloader.
[actions.boot-windows]
chainload.path = "\\EFI\\Microsoft\\Boot\\bootmgfw.efi"
```
## Step 4: Configure EFI Firmware to boot Sprout
It is not trivial to add an EFI boot entry inside Windows.
However, most firmware lets you load arbitrary EFI files from the firmware settings.
You can boot `\EFI\BOOT\sprout.efi` from firmware to boot Sprout.

View File

@@ -41,3 +41,8 @@ if [ -z "${QEMU_ACCEL}" ] && [ "${TARGET_ARCH}" = "${HOST_ARCH}" ] &&
grep -E '^flags.*:.+ vmx .*' /proc/cpuinfo >/dev/null; then
QEMU_ACCEL="kvm"
fi
if [ "$(uname)" = "Darwin" ] && [ "${TARGET_ARCH}" = "${HOST_ARCH}" ] &&
[ "$(sysctl -n kern.hv_support 2>&1 || true)" = "1" ]; then
QEMU_ACCEL="hvf"
fi

View File

@@ -19,7 +19,7 @@ elif [ "${TARGET_ARCH}" = "aarch64" ]; then
fi
if [ -n "${QEMU_ACCEL}" ]; then
set -- "${@}" "-accel" "kvm"
set -- "${@}" "-accel" "${QEMU_ACCEL}"
fi
if [ "${QEMU_GDB}" = "1" ]; then

View File

@@ -0,0 +1,11 @@
version = 1
[extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\shell.efi"
[actions.chainload-shell]
chainload.path = "$boot\\EFI\\BOOT\\shell.efi"
[entries.xen]
title = "Boot Shell"
actions = ["chainload-shell"]

View File

@@ -1,31 +1,36 @@
use crate::config::{RootConfiguration, latest_version};
use crate::options::SproutOptions;
use crate::utils;
use anyhow::{Context, Result, bail};
use log::info;
use std::ops::Deref;
use toml::Value;
use uefi::proto::device_path::LoadedImageDevicePath;
/// Loads the raw configuration from the sprout.toml file as data.
fn load_raw_config() -> Result<Vec<u8>> {
/// Loads the raw configuration from the sprout config file as data.
fn load_raw_config(options: &SproutOptions) -> Result<Vec<u8>> {
// Open the LoadedImageDevicePath protocol to get the path to the current image.
let current_image_device_path_protocol =
uefi::boot::open_protocol_exclusive::<LoadedImageDevicePath>(uefi::boot::image_handle())
.context("unable to get loaded image device path")?;
// Acquire the device path as a boxed device path.
let path = current_image_device_path_protocol.deref().to_boxed();
// Read the contents of the sprout.toml file.
let content = utils::read_file_contents(&path, "sprout.toml")
.context("unable to read sprout.toml file")?;
// Return the contents of the sprout.toml file.
info!("configuration file: {}", options.config);
// Read the contents of the sprout config file.
let content = utils::read_file_contents(&path, &options.config)
.context("unable to read sprout config file")?;
// Return the contents of the sprout config file.
Ok(content)
}
/// Loads the [RootConfiguration] for Sprout.
pub fn load() -> Result<RootConfiguration> {
// Load the raw configuration from the sprout.toml file.
let content = load_raw_config()?;
pub fn load(options: &SproutOptions) -> Result<RootConfiguration> {
// Load the raw configuration from the sprout config file.
let content = load_raw_config(options)?;
// Parse the raw configuration into a toml::Value which can represent any TOML file.
let value: Value = toml::from_slice(&content).context("unable to parse sprout.toml file")?;
let value: Value = toml::from_slice(&content).context("unable to parse sprout config file")?;
// Check the version of the configuration without parsing the full configuration.
let version = value

View File

@@ -1,4 +1,5 @@
use crate::actions::ActionDeclaration;
use crate::options::SproutOptions;
use anyhow::Result;
use anyhow::anyhow;
use std::collections::{BTreeMap, BTreeSet};
@@ -13,15 +14,18 @@ pub struct RootContext {
actions: BTreeMap<String, ActionDeclaration>,
/// The device path of the loaded Sprout image.
loaded_image_path: Option<Box<DevicePath>>,
/// The global options of Sprout.
options: SproutOptions,
}
impl RootContext {
/// Creates a new root context with the `loaded_image_device_path` which will be stored
/// in the context for easy access.
pub fn new(loaded_image_device_path: Box<DevicePath>) -> Self {
pub fn new(loaded_image_device_path: Box<DevicePath>, options: SproutOptions) -> Self {
Self {
actions: BTreeMap::new(),
loaded_image_path: Some(loaded_image_device_path),
options,
}
}
@@ -41,6 +45,11 @@ impl RootContext {
.as_deref()
.ok_or_else(|| anyhow!("no loaded image path"))
}
/// Access the global Sprout options.
pub fn options(&self) -> &SproutOptions {
&self.options
}
}
/// A context of Sprout. This is passed around different parts of Sprout and represents

View File

@@ -2,8 +2,10 @@
#![feature(uefi_std)]
use crate::context::{RootContext, SproutContext};
use crate::options::SproutOptions;
use crate::options::parser::OptionsRepresentable;
use crate::phases::phase;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result};
use log::info;
use std::collections::BTreeMap;
use std::ops::Deref;
@@ -37,6 +39,9 @@ pub mod phases;
/// setup: Code that initializes the UEFI environment for Sprout.
pub mod setup;
/// options: Parse the options of the Sprout executable.
pub mod options;
/// utils: Utility functions that are used by other parts of Sprout.
pub mod utils;
@@ -47,10 +52,13 @@ fn main() -> Result<()> {
// Initialize the basic UEFI environment.
setup::init()?;
// Parse the options to the sprout executable.
let options = SproutOptions::parse().context("unable to parse options")?;
// Load the configuration of sprout.
// At this point, the configuration has been validated and the specified
// version is checked to ensure compatibility.
let config = config::loader::load()?;
let config = config::loader::load(&options)?;
// Load the root context.
// This is done in a block to ensure the release of the LoadedImageDevicePath protocol.
@@ -64,7 +72,7 @@ fn main() -> Result<()> {
"loaded image path: {}",
loaded_image_path.to_string(DisplayOnly(false), AllowShortcuts(false))?
);
RootContext::new(loaded_image_path)
RootContext::new(loaded_image_path, options)
};
// Insert the configuration actions into the root context.
@@ -144,9 +152,14 @@ fn main() -> Result<()> {
// Execute the late phase.
phase(context.clone(), &config.phases.late).context("unable to execute late phase")?;
// Pick the first entry from the list of final entries until a boot menu is implemented.
let Some((context, entry)) = final_entries.first() else {
bail!("no entries found");
// Use the boot option if possible, otherwise pick the first entry.
let (context, entry) = if let Some(ref boot) = context.root().options().boot {
final_entries
.iter()
.find(|(_context, entry)| &entry.title == boot)
.context(format!("unable to find entry: {boot}"))?
} else {
final_entries.first().context("no entries found")?
};
// Execute all the actions for the selected entry.

84
src/options.rs Normal file
View File

@@ -0,0 +1,84 @@
use crate::options::parser::{OptionDescription, OptionForm, OptionsRepresentable};
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
/// The Sprout options parser.
pub mod parser;
/// Default configuration file path.
const DEFAULT_CONFIG_PATH: &str = "\\sprout.toml";
/// The parsed options of sprout.
#[derive(Debug)]
pub struct SproutOptions {
/// Path to a configuration file to load.
pub config: String,
/// Entry to boot without showing the boot menu.
pub boot: Option<String>,
}
/// The default Sprout options.
impl Default for SproutOptions {
fn default() -> Self {
Self {
config: DEFAULT_CONFIG_PATH.to_string(),
boot: None,
}
}
}
/// The options parser mechanism for Sprout.
impl OptionsRepresentable for SproutOptions {
/// Produce the [SproutOptions] structure.
type Output = Self;
/// All the Sprout options that are defined.
fn options() -> &'static [(&'static str, OptionDescription<'static>)] {
&[
(
"config",
OptionDescription {
description: "Path to Sprout configuration file",
form: OptionForm::Value,
},
),
(
"boot",
OptionDescription {
description: "Entry to boot, bypassing the menu",
form: OptionForm::Value,
},
),
(
"help",
OptionDescription {
description: "Display Sprout Help",
form: OptionForm::Help,
},
),
]
}
/// Produces [SproutOptions] from the parsed raw `options` map.
fn produce(options: BTreeMap<String, Option<String>>) -> Result<Self> {
// Use the default value of sprout options and have the raw options be parsed into it.
let mut result = Self::default();
for (key, value) in options {
match key.as_str() {
"config" => {
// The configuration file to load.
result.config = value.context("--config option requires a value")?;
}
"boot" => {
// The entry to boot.
result.boot = Some(value.context("--boot option requires a value")?);
}
_ => bail!("unknown option: --{key}"),
}
}
Ok(result)
}
}

150
src/options/parser.rs Normal file
View File

@@ -0,0 +1,150 @@
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
/// The type of option. This disambiguates different behavior
/// of how options are handled.
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub enum OptionForm {
/// A flag, like --verbose.
Flag,
/// A value, in the form --abc 123 or --abc=123.
Value,
/// Help flag, like --help.
Help,
}
/// The description of an option, used in the options parser
/// to make decisions about how to progress.
#[derive(Debug, Clone)]
pub struct OptionDescription<'a> {
/// The description of the option.
pub description: &'a str,
/// The type of option to parse as.
pub form: OptionForm,
}
/// Represents a type that can be parsed from command line arguments.
/// This is a super minimal options parser mechanism just for Sprout.
pub trait OptionsRepresentable {
/// The output type that parsing will produce.
type Output;
/// The configured options for this type. This should describe all the options
/// that are valid to produce the type. The left hand side is the name of the option,
/// and the right hand side is the description.
fn options() -> &'static [(&'static str, OptionDescription<'static>)];
/// Produces the type by taking the `options` and processing it into the output.
fn produce(options: BTreeMap<String, Option<String>>) -> Result<Self::Output>;
/// For minimalism, we don't want a full argument parser. Instead, we use
/// a simple --xyz = xyz: None and --abc 123 = abc: Some("123") format.
/// We also support --abc=123 = abc: Some("123") format.
fn parse_raw() -> Result<BTreeMap<String, Option<String>>> {
// Access the configured options for this type.
let configured: BTreeMap<_, _> = BTreeMap::from_iter(Self::options().to_vec());
// Collect all the arguments to Sprout.
// Skip the first argument which is the path to our executable.
let args = std::env::args().skip(1).collect::<Vec<_>>();
// Represent options as key-value pairs.
let mut options = BTreeMap::new();
// Iterators makes this way easier.
let mut iterator = args.into_iter().peekable();
loop {
// Consume the next option, if any.
let Some(option) = iterator.next() else {
break;
};
// If the doesn't start with --, that is invalid.
if !option.starts_with("--") {
bail!("invalid option: {option}");
}
// Strip the -- prefix off.
let mut option = option["--".len()..].trim().to_string();
// An optional value.
let mut value = None;
// Check if the option is of the form --abc=123
if option.contains("=") {
let Some((part_key, part_value)) = option.split_once("=") else {
bail!("invalid option: {option}");
};
let part_key = part_key.to_string();
let part_value = part_value.to_string();
option = part_key;
value = Some(part_value);
}
// Error on empty option names.
if option.is_empty() {
bail!("invalid empty option");
}
// Find the description of the configured option, if any.
let Some(description) = configured.get(option.as_str()) else {
bail!("invalid option: --{option}");
};
// Check if the option requires a value and error if none was provided.
if description.form == OptionForm::Value && value.is_none() {
// Check for the next value.
let maybe_next = iterator.peek();
// If the next value isn't another option, set the value to the next value.
// Otherwise, it is an empty string.
value = if let Some(next) = maybe_next
&& !next.starts_with("--")
{
iterator.next()
} else {
None
};
}
// If the option form does not support a value and there is a value, error.
if description.form != OptionForm::Value && value.is_some() {
bail!("option --{} does not take a value", option);
}
// Handle the --help flag case.
if description.form == OptionForm::Help {
// Generic configured options output.
println!("Configured Options:");
for (name, description) in &configured {
println!(
" --{}{}: {}",
name,
if description.form == OptionForm::Value {
" <value>"
} else {
""
},
description.description
);
}
// Exit because the help has been displayed.
std::process::exit(1);
}
// Insert the option and the value into the map.
options.insert(option, value);
}
Ok(options)
}
/// Parses the program arguments as a [Self::Output], calling [Self::parse_raw] and [Self::produce].
fn parse() -> Result<Self::Output> {
// Parse the program arguments into a raw map.
let options = Self::parse_raw().context("unable to parse options")?;
// Produce the options from the map.
Self::produce(options)
}
}