Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ We recommend running Sprout without Secure Boot for development, and with Secure
- [x] Load Linux initrd from disk
- [x] Devicetree support
- [x] Basic, simple, and graphical boot menus
- [x] A strict mode that follows the BLS specification and systemd-boot exactly
- [x] Generators for BLS entries, lists, and matrices, with variants
- [x] BLS autoconfiguration support
- [x] [Secure Boot support](https://github.com/edera-dev/sprout/issues/20): beta
Expand Down Expand Up @@ -137,6 +138,8 @@ $ sprout.efi --menu-timeout=10
$ sprout.efi --force-menu
# Keep the boot console as it is when an entry is booted.
$ sprout.efi --retain-boot-console
# Follow the BLS specification and systemd-boot exactly.
$ sprout.efi --bls-strict-mode
```

### Boot Linux from ESP
Expand Down Expand Up @@ -196,7 +199,7 @@ bls.path = "\\loader"
# the device of the path. an empty path turns unified kernel images off.
bls.uki-path = "\\EFI\\Linux"
# also read the extended boot loader partition (XBOOTLDR) of the same disk,
# which is sorted with the other entries. its files are on that partition, so
# which is sorted with the other entries. strict mode always does. its files are on that partition, so
# the action has to use $entry-root, which is empty for Sprout's own partition.
bls.xbootldr = false
# keep the name of the entry file as the name of the entry, so it matches
Expand All @@ -209,7 +212,9 @@ bls.entry.actions = ["boot-bls"]
chainload.path = "$entry-root\\$chainload"
chainload.options = ["$options"]
chainload.devicetree = "$entry-root\\$devicetree"
# an entry can have up to eight initrds. unused ones are skipped.
# an entry can have up to 32 initrds, as $initrd-0 to $initrd-31. unused ones are skipped.
# only the first eight are listed here. add the others to boot an entry with more,
# as Sprout warns when an entry has an initrd that the chain does not list.
chainload.linux-initrd-chain = [
"$entry-root\\$initrd-0",
"$entry-root\\$initrd-1",
Expand All @@ -224,6 +229,7 @@ chainload.linux-initrd-chain = [

An entry that names a `devicetree` boots with that devicetree installed for the image, which is put
back when the image returns. It is not used when Secure Boot is enabled, as it can't be verified.
If it can't be installed, Sprout warns and boots without it, unless strict mode is on.

#### Boot counting

Expand Down Expand Up @@ -262,14 +268,42 @@ Sprout uses the same variables as systemd-boot, so `bootctl`, `systemctl reboot
`LoaderEntryPreferred`, `LoaderEntryOneShot`, `LoaderEntryLastBooted`, `LoaderConfigTimeout`,
and `LoaderConfigTimeoutOneShot`.

The default entry comes from the first of these that matches an entry:
The values that tools like `bootctl` set in the bootloader interface outrank `sprout.toml`, which outranks
`loader.conf`. In strict mode, `LoaderEntryOneShot` is tried before everything else for the default entry.

1. `default-entry` in `sprout.toml`
2. `LoaderEntryPreferred`, then `preferred` in `loader.conf`
3. `LoaderEntryDefault`, then `default` in `loader.conf`
The default entry comes from the first of these that matches an entry:

The menu timeout comes from the first of the one-shot timeout, `--menu-timeout`, `menu-timeout` in
`sprout.toml`, `LoaderConfigTimeout`, and `loader.conf`.
1. `LoaderEntryPreferred`, then `preferred` in `loader.conf`
2. `LoaderEntryDefault`
3. `default-entry` in `sprout.toml`
4. `default` in `loader.conf`

The menu timeout comes from the first of the one-shot timeout, `--menu-timeout`, `LoaderConfigTimeout`,
`menu-timeout` in `sprout.toml`, and `loader.conf`.

#### Strict mode

By default, Sprout differs from systemd-boot in a few places that are friendlier or safer for a bootloader that
reads the same files. Each of them is logged when it applies. `--bls-strict-mode`, or `bls-strict-mode = true`
in the options of `sprout.toml`, removes all of them.

| Behavior | By default | In strict mode |
|------------------------------------------|-----------------------------------------------------------|-----------------------------------------------------------|
| One-shot entry (`LoaderEntryOneShot`) | Booted at once, without the menu. | Only the default for this boot. The menu and its timeout still apply. |
| Menu timeout that nothing sets | The menu is shown for 10 seconds. | The menu is hidden. |
| Default entry with no boot counter tries | Skipped for another entry. | Used, as `default` ignores the tries. |
| Entry with more than one of `linux`, `efi`, `uki` | Boots the first of them. | Hidden. |
| Entry whose file does not exist | Shown, and it fails when booted. | Hidden. |
| Unified kernel image without a name | Named after its file. | Hidden. |
| Name and version of a unified kernel image | The name is `PRETTY_NAME` or `ID`, and the version is `IMAGE_VERSION`, `VERSION_ID`, `BUILD_ID`, then `.uname`. | The fields systemd-boot uses: the name is `PRETTY_NAME`, `IMAGE_ID`, `NAME` or `ID`, and the version is `IMAGE_VERSION`, `VERSION`, `VERSION_ID` or `BUILD_ID`. |
| Boot counter of a plain `efi` entry | Counted. | Not counted, but its tries still make it bad. |
| The extended boot loader partition | Read when `xbootldr` is set on the generator. | Always read. |
| Autoconfiguration | Reads BLS entries from every disk, one generator each. | Reads BLS entries from Sprout's partition and the XBOOTLDR of its disk, as one generator. Windows and Linux entries are found as before. |
| Boot entry that returns | Sprout exits to the firmware. | The menu is shown again. |
| Devicetree that can't be installed | Warns and boots without it. | The entry fails. |

In strict mode, the actions of a hand-written generator have to use `$entry-root` for the files of
an entry, as entries can come from the extended boot loader partition.

### Generators

Expand Down
63 changes: 63 additions & 0 deletions crates/bls/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ impl BlsEntry {
self.linux.is_some() || self.efi.is_some() || self.uki.is_some()
}

/// Whether the entry names more than one of `linux`, `efi` and `uki` to boot.
/// systemd-boot considers such an entry broken and does not show it.
pub fn mixes_boot_targets(&self) -> bool {
[&self.linux, &self.efi, &self.uki]
.iter()
.filter(|target| target.is_some())
.count()
> 1
}

/// Whether the entry only boots a unified kernel image, which carries its own initrd.
fn is_uki_only(&self) -> bool {
self.linux.is_none() && self.efi.is_none() && self.uki.is_some()
Expand Down Expand Up @@ -806,6 +816,59 @@ mod tests {
assert!(entry.is_valid());
}

#[test]
fn uki_title_and_version_use_every_systemd_fallback_in_strict_mode() {
let sections = uki_sections(&[(
".osrel",
"IMAGE_ID=img\nNAME=N\nID=i\nVERSION=7.1\nVERSION_ID=7\n",
)]);
let entry = BlsEntry::from_uki_with(&sections, "/a.efi", true);
assert_eq!(entry.title.as_deref(), Some("img"));
assert_eq!(entry.version.as_deref(), Some("7.1"));

let sections = uki_sections(&[(".osrel", "NAME=N\nID=i\n")]);
let entry = BlsEntry::from_uki_with(&sections, "/a.efi", true);
assert_eq!(entry.title.as_deref(), Some("N"));
}

#[test]
fn uki_title_and_version_keep_the_sprout_fallbacks_outside_strict_mode() {
// The long VERSION would be added to the title, so it is not used.
let sections = uki_sections(&[(
".osrel",
"IMAGE_ID=img\nNAME=N\nID=i\nVERSION=\"24.04 LTS (Noble)\"\nVERSION_ID=7\n",
)]);
let entry = BlsEntry::from_uki(&sections, "/a.efi");
assert_eq!(entry.title.as_deref(), Some("i"));
assert_eq!(entry.version.as_deref(), Some("7"));
}

#[test]
fn uki_version_only_falls_back_to_uname_outside_strict_mode() {
let sections = uki_sections(&[(".osrel", "ID=a\n"), (".uname", "6.1.2\n")]);
let loose = BlsEntry::from_uki_with(&sections, "/a.efi", false);
assert_eq!(loose.version.as_deref(), Some("6.1.2"));
let strict = BlsEntry::from_uki_with(&sections, "/a.efi", true);
assert_eq!(strict.version, None);
// The kernel version is still there, it is just not the version of the entry.
assert_eq!(strict.uname.as_deref(), Some("6.1.2"));
}

#[test]
fn entries_that_mix_boot_targets_are_found() {
for (input, mixed) in [
("linux /v\n", false),
("efi /e.efi\n", false),
("uki /u.efi\n", false),
("linux /v\nefi /e.efi\n", true),
("linux /v\nuki /u.efi\n", true),
("efi /e.efi\nuki /u.efi\n", true),
] {
let entry: BlsEntry = input.parse().unwrap();
assert_eq!(entry.mixes_boot_targets(), mixed, "{input}");
}
}

#[test]
fn uki_entry_falls_back_through_the_fields() {
let sections = uki_sections(&[(".osrel", "ID=arch\nBUILD_ID=rolling\n")]);
Expand Down
43 changes: 36 additions & 7 deletions crates/bls/src/uki.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ impl BlsEntry {
/// Produces an entry for one profile of the unified kernel image at `uki_path`.
/// A profile after the first is booted with `@<number>` in its load options.
pub fn from_uki_profile(profile: &UkiProfile, uki_path: &str) -> Self {
let mut entry = Self::from_uki(&profile.sections, uki_path);
Self::from_uki_profile_with(profile, uki_path, false)
}

/// Like [BlsEntry::from_uki_profile], and `strict` follows systemd-boot exactly.
pub fn from_uki_profile_with(profile: &UkiProfile, uki_path: &str, strict: bool) -> Self {
let mut entry = Self::from_uki_with(&profile.sections, uki_path, strict);
if let Some(index) = profile.index {
entry.profile = (index > 0).then(|| index.to_string());
entry.title = entry
Expand All @@ -87,11 +92,22 @@ impl BlsEntry {
}

/// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`.
/// The title comes from `PRETTY_NAME`, then `ID`. The version comes from `IMAGE_VERSION`,
/// `VERSION_ID`, `BUILD_ID`, then the `.uname` section. The sort key comes from `IMAGE_ID`,
/// then `ID`. The embedded command line is kept in `cmdline` and not in `options`, as the
/// image reads its own command line.
pub fn from_uki(sections: &BTreeMap<String, Vec<u8>>, uki_path: &str) -> Self {
Self::from_uki_with(sections, uki_path, false)
}

/// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`.
/// With `strict`, the title comes from `PRETTY_NAME`, `IMAGE_ID`, `NAME`, then `ID`, and the
/// version from `IMAGE_VERSION`, `VERSION`, `VERSION_ID`, then `BUILD_ID`, as in systemd-boot.
/// Otherwise the title comes from `PRETTY_NAME`, then `ID`, and the version from
/// `IMAGE_VERSION`, `VERSION_ID`, `BUILD_ID`, then the `.uname` section. The sort key comes
/// from `IMAGE_ID`, then `ID`. The embedded command line is kept in `cmdline` and not in `options`, as the image
/// reads its own command line.
pub fn from_uki_with(
sections: &BTreeMap<String, Vec<u8>>,
uki_path: &str,
strict: bool,
) -> Self {
let os_release = section_text(sections, ".osrel")
.map(|text| OsRelease::parse(&text))
.unwrap_or_default();
Expand All @@ -102,10 +118,23 @@ impl BlsEntry {
.map(ToString::to_string)
};
let uname = section_text(sections, ".uname");
// systemd-boot looks at more fields. Outside of strict mode the version is not taken from
// VERSION, which is often a long name that would be added to the title.
let (title, version) = if strict {
(
first(&["PRETTY_NAME", "IMAGE_ID", "NAME", "ID"]),
first(&["IMAGE_VERSION", "VERSION", "VERSION_ID", "BUILD_ID"]),
)
} else {
(
first(&["PRETTY_NAME", "ID"]),
first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]),
)
};

Self {
title: first(&["PRETTY_NAME", "ID"]),
version: first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]).or_else(|| uname.clone()),
title,
version: version.or_else(|| uname.clone().filter(|_| !strict)),
sort_key: first(&["IMAGE_ID", "ID"]),
uki: Some(uki_path.to_string()),
cmdline: section_text(sections, ".cmdline"),
Expand Down
54 changes: 46 additions & 8 deletions crates/boot/src/actions/chainload.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::context::SproutContext;
use crate::generators::bls::BLS_INITRD_SLOTS;
use crate::phases::before_handoff;
use alloc::boxed::Box;
use alloc::format;
Expand Down Expand Up @@ -62,6 +63,22 @@ fn resolve_optional_file(
Ok(Some(resolved))
}

/// Install the devicetree at the stamped `path` for the image that is about to be started.
/// Provides [None] if there is none to install, such as when the path refers to the root of a
/// filesystem, or when Secure Boot is enabled, as a devicetree can't be verified.
fn install_devicetree(context: &Rc<SproutContext>, path: &str) -> Result<Option<DeviceTree>> {
let Some(resolved) = resolve_optional_file(context, path, "devicetree")? else {
return Ok(None);
};
if SecureBoot::enabled().unwrap_or(true) {
warn!("ignoring the devicetree, as Secure Boot is enabled");
return Ok(None);
}
let content = resolved.read_file().context("unable to read devicetree")?;
let devicetree = DeviceTree::install(&content).context("unable to install the devicetree")?;
Ok(Some(devicetree))
}

/// Unloads the image with the contained handle when dropped, unless the handle is taken.
/// This ensures that an image that is loaded but never started is not left in memory.
struct UnloadGuard(Option<Handle>);
Expand Down Expand Up @@ -144,6 +161,24 @@ pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfigurat
.chain(configuration.linux_initrd_chain.iter())
.map(|item| context.stamp(item));

// A BLS entry can have more initrds than the chain lists. Those would be dropped without a
// word, and then the kernel may not find its root, so say so.
let chain_length = configuration.linux_initrd_chain.len();
if chain_length > 0 {
for slot in chain_length..BLS_INITRD_SLOTS {
let placeholder = format!("$initrd-{}", slot);
let value = context.stamp(&placeholder);
if !value.is_empty() && value != placeholder {
warn!(
"the entry has an initrd for slot {}, but linux-initrd-chain only lists {}, \
so it is not loaded",
slot, chain_length
);
break;
}
}
}

// Read each initrd and concatenate the contents in order.
// Paths that are empty after stamping are skipped.
let mut initrd: Option<Vec<u8>> = None;
Expand Down Expand Up @@ -180,14 +215,17 @@ pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfigurat
.devicetree
.as_ref()
.map(|path| context.stamp(path)),
) && let Some(resolved) = resolve_optional_file(&context, &path, "devicetree")?
{
if SecureBoot::enabled().unwrap_or(true) {
warn!("ignoring the devicetree, as Secure Boot is enabled");
} else {
let content = resolved.read_file().context("unable to read devicetree")?;
devicetree =
Some(DeviceTree::install(&content).context("unable to install the devicetree")?);
) {
match install_devicetree(&context, &path) {
Ok(installed) => devicetree = installed,
// systemd-boot does not boot an entry whose devicetree can't be installed. Outside
// of strict mode it is booted without it, as before the devicetree was supported.
Err(error) if !context.root().options().bls_strict_mode => warn!(
"booting without the devicetree {}, as it can't be installed: {:#} \
(systemd-boot and strict mode do not boot the entry)",
path, error
),
Err(error) => return Err(error),
}
}

Expand Down
Loading
Loading