42 Commits

Author SHA1 Message Date
a10a5cb342 sprout: version 0.0.11 2025-10-27 04:00:37 -04:00
fdc5f0e1d2 chore(docs): mention autoconfiguration support for bls and use it in the docs 2025-10-27 03:52:39 -04:00
f60cf4b365 Merge pull request #18 from edera-dev/azenla/autoconfigure
Autoconfiguration Support
2025-10-27 03:48:51 -04:00
0ca9ff4fec fix(bls-autoconfigure): generate a unique chainload action for each filesystem 2025-10-27 03:45:10 -04:00
1799419bfa fix(autoconfigure): apply the actions properly in the root 2025-10-27 03:37:09 -04:00
facd2000a5 feat(autoconfigure): initial attempt at bls autoconfiguration 2025-10-27 02:38:40 -04:00
7dd910a74f sprout: version 0.0.10 2025-10-27 00:53:35 -04:00
187a84fcf8 fix(bls): entries path should be constructed from the subpath, not the device path 2025-10-27 00:50:17 -04:00
e47c813536 fix(bls): swap from intaking \\loader\\entries to just \\loader
This prepares for further BLS integration.
2025-10-27 00:31:07 -04:00
094128de58 fix(log): swap everything to use logging 2025-10-27 00:21:24 -04:00
e8a4fa5053 Revert "fix(log): make all logging debug! instead of info!"
This reverts commit 717e7716ba.
2025-10-27 00:20:42 -04:00
717e7716ba fix(log): make all logging debug! instead of info! 2025-10-27 00:16:00 -04:00
1d32855d22 Merge pull request #16 from edera-dev/azenla/alpine-edge
chore(docs): alpine edge setup guide
2025-10-27 00:15:28 -04:00
93c7a35c62 Merge pull request #17 from edera-dev/azenla/basic-menu
feat(boot): basic boot menu
2025-10-27 00:04:54 -04:00
8b6317f221 chore(doc): document the basic boot menu as a supported feature 2025-10-27 00:01:05 -04:00
4bbac3e4d5 feat(boot): implement basic boot menu 2025-10-26 23:59:50 -04:00
1f48d26385 chore(docs): alpine edge setup guide 2025-10-26 22:29:30 -04:00
9d2e25183b Merge pull request #15 from edera-dev/azenla/defaults-boot
feat(config): support for setting the default entry to boot
2025-10-24 21:30:03 -07:00
734ff117db feat(config): support for setting the default entry to boot 2025-10-24 21:19:38 -07:00
fbebedd66a fix(hack): format should use bash to use glob 2025-10-24 20:09:32 -07:00
b3bf564b65 fix(hack): formatting fixes 2025-10-24 20:08:46 -07:00
340c280c00 fix(hack): check kvm with /dev/kvm instead of cpu flags 2025-10-24 20:07:41 -07:00
7a72b7af5b Merge pull request #14 from edera-dev/azenla/fixes
Repair automated bug analysis issues.
2025-10-24 20:00:49 -07:00
0c2303d789 fix(framebuffer): add proper bounds checking for accessing a pixel 2025-10-24 19:54:28 -07:00
6cd502ef18 fix(actions): if edera action returns successfully, an intended unreachable line could be reached 2025-10-24 19:51:08 -07:00
e243228f15 fix(framebuffer): check width, height and implement proper checking when accessing pixels 2025-10-24 19:44:26 -07:00
2253fa2a1f fix(context): make sure to actually iterate longest first for key replacement 2025-10-24 19:40:40 -07:00
057c48f9f7 fix(bls): parser should skip over empty lines and comments 2025-10-24 19:31:01 -07:00
45d7cd2d3b fix(doc): incorrect comment for startup phase execution 2025-10-24 19:28:38 -07:00
482db0b763 fix(media-loader): eliminate usage of unwrap and swap to result 2025-10-24 19:27:43 -07:00
a15c92a749 fix(context): ensure longer keys are replaced first, fixing key replacement edge case 2025-10-24 19:24:29 -07:00
7d5248e2ee fix(context): skip over empty keys to avoid replacing $ and breaking other values 2025-10-24 19:12:43 -07:00
41fbca6f76 fix(utils): clarify that the to_string().contains() is necessary due to CString16 2025-10-24 19:11:17 -07:00
d39fbae168 fix(splash): check for zero-sized images 2025-10-24 19:08:02 -07:00
0b0b4dc19d chore(perf): replace some string replacement and comparison with characters for performance 2025-10-24 18:59:15 -07:00
86fa00928e fix(bls): convert less safe path concatenation to use path buffer 2025-10-24 18:56:11 -07:00
4c7b1d70ef fix(bls): parsing of entries should split by whitespace, not just spaces 2025-10-24 18:51:34 -07:00
9d2c31f77f fix(options): clarify code that checks for --abc=123 option form 2025-10-24 18:49:14 -07:00
fc710ec391 fix(options): --help should exit with code zero 2025-10-24 18:47:34 -07:00
9f7ca672ea chore(filesystem-device-match): add clarity to statement which is unreachable 2025-10-24 18:46:45 -07:00
2a2aa74c09 fix(context): add context finalization iteration limit
This prevents any possibility of an infinite loop during finalization.
2025-10-24 18:44:54 -07:00
2e3399f33f chore(deps): upgrade to uefi v0.36.0 2025-10-24 18:34:18 -07:00
42 changed files with 855 additions and 205 deletions

50
Cargo.lock generated
View File

@@ -28,9 +28,9 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "2.9.4"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bytemuck"
@@ -46,9 +46,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cfg-if"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crc32fast"
@@ -61,7 +61,7 @@ dependencies = [
[[package]]
name = "edera-sprout"
version = "0.0.9"
version = "0.0.11"
dependencies = [
"anyhow",
"image",
@@ -89,9 +89,9 @@ dependencies = [
[[package]]
name = "flate2"
version = "1.1.3"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04bcaeafafdd3cd1cb5d986ff32096ad1136630207c49b9091e3ae541090d938"
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
dependencies = [
"crc32fast",
"miniz_oxide",
@@ -118,9 +118,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.11.4"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
dependencies = [
"equivalent",
"hashbrown",
@@ -175,9 +175,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.101"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
@@ -204,9 +204,9 @@ dependencies = [
[[package]]
name = "pxfm"
version = "0.1.24"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
@@ -266,9 +266,9 @@ source = "git+https://github.com/edera-dev/sprout-patched-deps.git?rev=2c4fcc84b
[[package]]
name = "syn"
version = "2.0.106"
version = "2.0.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
dependencies = [
"proc-macro2",
"quote",
@@ -325,9 +325,9 @@ dependencies = [
[[package]]
name = "uefi"
version = "0.35.0"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7569ceafb898907ff764629bac90ac24ba4203c38c33ef79ee88c74aa35b11"
checksum = "f123e69767fc287c44d70ee19af3b39d1bfb735dbaff5090e95b5b13cd656d16"
dependencies = [
"bitflags",
"cfg-if",
@@ -341,9 +341,9 @@ dependencies = [
[[package]]
name = "uefi-macros"
version = "0.18.1"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3dad47b3af8f99116c0f6d4d669c439487d9aaf1c8d9480d686cda6f3a8aa23"
checksum = "4687412b5ac74d245d5bfb1733ede50c31be19bf8a4b6a967a29b451bab49e67"
dependencies = [
"proc-macro2",
"quote",
@@ -352,9 +352,9 @@ dependencies = [
[[package]]
name = "uefi-raw"
version = "0.11.0"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cad96b8baaf1615d3fdd0f03d04a0b487d857c1b51b19dcbfe05e2e3c447b78"
checksum = "8aff2f4f2b556a36a201d335a1e0a57754967a96857b1f47a52d5a23825cac84"
dependencies = [
"bitflags",
"uguid",
@@ -362,15 +362,15 @@ dependencies = [
[[package]]
name = "uguid"
version = "2.2.0"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab14ea9660d240e7865ce9d54ecdbd1cd9fa5802ae6f4512f093c7907e921533"
checksum = "0c8352f8c05e47892e7eaf13b34abd76a7f4aeaf817b716e88789381927f199c"
[[package]]
name = "unicode-ident"
version = "1.0.19"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
[[package]]
name = "winnow"

View File

@@ -2,7 +2,7 @@
name = "edera-sprout"
description = "Modern UEFI bootloader"
license = "Apache-2.0"
version = "0.0.9"
version = "0.0.11"
homepage = "https://sprout.edera.dev"
repository = "https://github.com/edera-dev/sprout"
edition = "2024"
@@ -23,11 +23,11 @@ version = "1.0.228"
features = ["derive"]
[dependencies.uefi]
version = "0.35.0"
version = "0.36.0"
features = ["alloc", "logger"]
[dependencies.uefi-raw]
version = "0.11.0"
version = "0.12.0"
[features]
default = ["splash"]

View File

@@ -39,6 +39,7 @@ simplify installation and usage.
- [Fedora Setup Guide]
- [Generic Linux Setup Guide]
- [Alpine Edge Setup Guide]
- [Windows Setup Guide]
- [Development Guide]
- [Contributing Guide]
@@ -48,8 +49,8 @@ simplify installation and usage.
## Features
NOTE: Currently, Sprout is experimental and is not intended for production use. For example, it doesn't currently
have secure boot support. In fact, as of writing, it doesn't even have a boot menu. Instead, it boots the first entry it sees, or fails.
NOTE: Currently, Sprout is experimental and is not intended for production use.
The boot menu mechanism is very rudimentary.
### Current
@@ -59,11 +60,12 @@ have secure boot support. In fact, as of writing, it doesn't even have a boot me
- [x] Linux boot support via EFI stub
- [x] Windows boot support via chainload
- [x] Load Linux initrd from disk
- [x] Boot first configured entry
- [x] Basic boot menu
- [x] BLS autoconfiguration support
### Roadmap
- [ ] Boot menu
- [ ] Full-featured boot menu
- [ ] Secure Boot support: work in progress
- [ ] UKI support: partial
- [ ] multiboot2 support
@@ -99,6 +101,8 @@ Sprout supports some command line options that can be combined to modify behavio
$ sprout.efi --config=\path\to\config.toml
# Boot a specific entry, bypassing the menu.
$ sprout.efi --boot="Boot Xen"
# Autoconfigure Sprout, without loading a configuration file.
$ sprout.efi --autoconfigure
```
### Boot Linux from ESP
@@ -133,32 +137,17 @@ version = 1
[drivers.ext4]
path = "\\sprout\\drivers\\ext4.efi"
# extract the full path of the first filesystem
# that contains \loader\entries as a directory
# into the value called "boot"
[extractors.boot.filesystem-device-match]
has-item = "\\loader\\entries"
# use the sprout bls module to scan a bls
# directory for entries and load them as boot
# entries in sprout, using the entry template
# as specified here. the bls action below will
# be passed the extracted values from bls.
[generators.boot.bls]
path = "$boot\\loader\\entries"
entry.title = "$title"
entry.actions = ["bls"]
# the action that is used for each bls entry above.
[actions.bls]
chainload.path = "$boot\\$chainload"
chainload.options = ["$options"]
chainload.linux-initrd = "$boot\\$initrd"
# global options.
[defaults]
# enable autoconfiguration by detecting bls enabled
# filesystems and generating boot entries for them.
autoconfigure = true
```
[Edera]: https://edera.dev
[Fedora Setup Guide]: ./docs/fedora-setup.md
[Generic Linux Setup Guide]: ./docs/generic-linux-setup.md
[Alpine Edge Setup Guide]: ./docs/alpine-edge-setup.md
[Windows Setup Guide]: ./docs/windows-setup.md
[Development Guide]: ./DEVELOPMENT.md
[Contributing Guide]: ./CONTRIBUTING.md

113
docs/alpine-edge-setup.md Normal file
View File

@@ -0,0 +1,113 @@
# Setup Sprout on Alpine Edge
## Prerequisites
- Alpine Edge
- EFI System Partition mounted on `/boot/efi` (the default)
- ext4 or FAT32/exFAT formatted `/boot` partition
## Step 1: Base Installation
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 `/boot/efi/EFI/boot/sprout.efi` on your EFI System Partition.
Additionally, you will want to install the `efifs` package, which provides the filesystem support for Sprout.
```bash
# Install the efifs package which provides filesystem support for Sprout.
$ apk install efifs
```
## Step 2: Configure Sprout
Since Alpine uses standard image paths based on the `linux` package installed, it's quite easy to configure Sprout
to boot Alpine.
Write the following file to `/boot/efi/sprout.toml`:
```toml
# sprout configuration: version 1
version = 1
# load an EFI driver for ext2/ext3/ext4.
[drivers.ext2]
path = "\\EFI\\efifs\\ext2.efi"
# extract the full path of the first filesystem
# that contains \boot\vmlinuz-stable as a file
# into the value called "root"
[extractors.root.filesystem-device-match]
has-item = "\\boot\\vmlinuz-stable"
# add a boot entry for booting linux
# which will run the boot-linux action.
[entries.boot-linux-stable]
title = "Boot Linux Stable"
actions = ["boot-linux-stable"]
# use the chainload action to boot linux-stable 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-stable]
chainload.path = "$root\\boot\\vmlinuz-stable"
chainload.options = ["root=/dev/sda1", "my-kernel-option"]
chainload.linux-initrd = "$root\\boot\\initramfs-stable"
```
You can replace `vmlinuz-stable` and `initramfs-stable` with the actual
files for the `linux` package you have installed. For example, for `linux-lts` it is `vmlinuz-lts` and `initramfs-lts`.
## Step 3, Option 1: Configure GRUB to load Sprout (recommended)
You can configure GRUB to add a boot entry for Sprout, so you can continue to use GRUB without interruption.
GRUB needs to be configured with the chainloader module to load Sprout.
You will need to find the UUID of your EFI System Partition. You can do this by running the following command:
```bash
$ grep "/boot/efi" /etc/fstab | awk '{print $1}' | awk -F '=' '{print $2}'
SAMPLE-VALUE
```
The GRUB configuration for Sprout is as follows, replace `SAMPLE-VALUE` with the UUID of your EFI System Partition:
```grub
menuentry 'Sprout' $menuentry_id_option 'sprout' {
insmod part_gpt
insmod fat
insmod chain
search --no-floppy --fs-uuid --set=root SAMPLE-VALUE
chainloader /EFI/boot/sprout.efi
}
```
You can append this to `/etc/grub.d/40_custom` and run the following command to update your configuration:
```bash
$ update-grub
```
To update your GRUB configuration.
You may now reboot your system and select Sprout from the GRUB menu.
## Step 3, Option 2: Configure your EFI firmware for Sprout
You can configure your EFI boot menu to show Sprout as an option.
You will need to install the `efibootmgr` package:
```
$ apk add efibootmgr
```
Once `efibootmgr` is installed, find the partition device of your EFI System Partition and run the following:
```bash
$ 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.

View File

@@ -39,27 +39,11 @@ version = 1
[drivers.ext4]
path = "\\sprout\\drivers\\ext4.efi"
# extract the full path of the first filesystem
# that contains \loader\entries as a directory
# into the value called "boot"
[extractors.boot.filesystem-device-match]
has-item = "\\loader\\entries"
# use the sprout bls module to scan a bls
# directory for entries and load them as boot
# entries in sprout, using the entry template
# as specified here. the bls action below will
# be passed the extracted values from bls.
[generators.boot.bls]
path = "$boot\\loader\\entries"
entry.title = "$title"
entry.actions = ["bls"]
# the action that is used for each bls entry above.
[actions.bls]
chainload.path = "$boot\\$chainload"
chainload.options = ["$options"]
chainload.linux-initrd = "$boot\\$initrd"
# global options.
[defaults]
# enable autoconfiguration by detecting bls enabled
# filesystems and generating boot entries for them.
autoconfigure = true
```
## Step 3, Option 1: Configure GRUB to load Sprout (recommended)

View File

@@ -36,9 +36,7 @@ DOCKER_TARGET="linux/${TARGET_ARCH}"
FINAL_DIR="target/final/${TARGET_ARCH}"
ASSEMBLE_DIR="target/assemble"
if [ -z "${QEMU_ACCEL}" ] && [ "${TARGET_ARCH}" = "${HOST_ARCH}" ] &&
[ -f "/proc/cpuinfo" ] &&
grep -E '^flags.*:.+ vmx .*' /proc/cpuinfo >/dev/null; then
if [ -z "${QEMU_ACCEL}" ] && [ "${TARGET_ARCH}" = "${HOST_ARCH}" ] && [ -e "/dev/kvm" ]; then
QEMU_ACCEL="kvm"
fi

View File

@@ -35,13 +35,15 @@ set -- "${@}" -smp 2 -m 4096
if [ "${NO_GRAPHICAL_BOOT}" = "1" ]; then
set -- "${@}" -nographic
else
if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then
set -- "${@}" -serial stdio
else
set -- "${@}" \
-device virtio-serial-pci,id=vs0 \
-chardev stdio,id=stdio0 \
-device virtconsole,chardev=stdio0,id=console0
if [ "${GRAPHICAL_ONLY}" != "1" ]; then
if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then
set -- "${@}" -serial stdio
else
set -- "${@}" \
-device virtio-serial-pci,id=vs0 \
-chardev stdio,id=stdio0 \
-device virtconsole,chardev=stdio0,id=console0
fi
fi
if [ "${QEMU_LEGACY_VGA}" = "1" ]; then
@@ -63,13 +65,8 @@ set -- "${@}" \
-drive "if=pflash,file=${FINAL_DIR}/ovmf-boot.fd,format=raw,readonly=on" \
-device nvme,drive=disk1,serial=cafebabe
if [ "${DISK_BOOT}" = "1" ]; then
set -- "${@}" \
-drive "if=none,file=${FINAL_DIR}/sprout.img,format=raw,id=disk1,readonly=on"
else
set -- "${@}" \
-drive "if=none,file=fat:rw:${FINAL_DIR}/efi,format=raw,id=disk1"
fi
set -- "${@}" \
-drive "if=none,file=${FINAL_DIR}/sprout.img,format=raw,id=disk1,readonly=on"
set -- "${@}" -name "sprout ${TARGET_ARCH}"

View File

@@ -13,6 +13,7 @@ COPY xen.efi /work/XEN.EFI
COPY xen.cfg /work/XEN.CFG
COPY initramfs /work/INITRAMFS
COPY edera-splash.png /work/EDERA-SPLASH.PNG
COPY bls.conf /work/BLS.CONF
RUN truncate -s128MiB sprout.img && \
parted --script sprout.img mklabel gpt > /dev/null 2>&1 && \
parted --script sprout.img mkpart primary fat32 1MiB 100% > /dev/null 2>&1 && \
@@ -20,6 +21,8 @@ RUN truncate -s128MiB sprout.img && \
mkfs.vfat -F32 -n EFI sprout.img && \
mmd -i sprout.img ::/EFI && \
mmd -i sprout.img ::/EFI/BOOT && \
mmd -i sprout.img ::/LOADER && \
mmd -i sprout.img ::/LOADER/ENTRIES && \
mcopy -i sprout.img ${EFI_NAME}.EFI ::/EFI/BOOT/ && \
mcopy -i sprout.img KERNEL.EFI ::/EFI/BOOT/ && \
mcopy -i sprout.img SHELL.EFI ::/EFI/BOOT/ && \
@@ -28,6 +31,7 @@ RUN truncate -s128MiB sprout.img && \
mcopy -i sprout.img SPROUT.TOML ::/ && \
mcopy -i sprout.img EDERA-SPLASH.PNG ::/ && \
mcopy -i sprout.img INITRAMFS ::/ && \
mcopy -i sprout.img BLS.CONF ::/LOADER/ENTRIES/ && \
mv sprout.img /sprout.img
FROM scratch AS final

View File

@@ -11,7 +11,7 @@ if [ "${TARGET_ARCH}" = "aarch64" ]; then
fi
if [ -z "${SPROUT_CONFIG_NAME}" ]; then
SPROUT_CONFIG_NAME="kernel"
SPROUT_CONFIG_NAME="all"
fi
echo "[build] ${TARGET_ARCH} ${RUST_PROFILE}"
@@ -108,6 +108,7 @@ if [ "${SKIP_SPROUT_BUILD}" != "1" ]; then
cp "hack/dev/configs/${SPROUT_CONFIG_NAME}.sprout.toml" "${FINAL_DIR}/sprout.toml"
cp "hack/dev/configs/xen.cfg" "${FINAL_DIR}/xen.cfg"
cp "hack/dev/assets/edera-splash.png" "${FINAL_DIR}/edera-splash.png"
cp "hack/dev/configs/bls.conf" "${FINAL_DIR}/bls.conf"
mkdir -p "${FINAL_DIR}/efi/EFI/BOOT"
cp "${FINAL_DIR}/sprout.efi" "${FINAL_DIR}/efi/EFI/BOOT/${EFI_NAME}.EFI"

View File

@@ -0,0 +1,30 @@
version = 1
[defaults]
entry = "kernel"
[extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\kernel.efi"
[actions.chainload-kernel]
chainload.path = "$boot\\EFI\\BOOT\\kernel.efi"
chainload.options = ["console=hvc0"]
chainload.linux-initrd = "$boot\\initramfs"
[entries.kernel]
title = "Boot Linux"
actions = ["chainload-kernel"]
[actions.chainload-shell]
chainload.path = "$boot\\EFI\\BOOT\\shell.efi"
[entries.shell]
title = "Boot Shell"
actions = ["chainload-shell"]
[actions.chainload-xen]
chainload.path = "$boot\\EFI\\BOOT\\xen.efi"
[entries.xen]
title = "Boot Xen"
actions = ["chainload-xen"]

View File

@@ -0,0 +1,4 @@
version = 1
[defaults]
autoconfigure = true

View File

@@ -0,0 +1,4 @@
title Boot Linux
linux /efi/boot/kernel.efi
options console=hvc0
initrd /initramfs

View File

@@ -1,5 +1,9 @@
version = 1
[defaults]
entry = "edera"
menu-timeout = 0
[extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\xen.efi"

View File

@@ -1,5 +1,9 @@
version = 1
[defaults]
entry = "kernel"
menu-timeout = 0
[extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\kernel.efi"

View File

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

View File

@@ -1,5 +1,9 @@
version = 1
[defaults]
entry = "xen"
menu-timeout = 0
[extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\xen.efi"

View File

@@ -29,11 +29,10 @@ else
fi
make CROSS_COMPILE="${MAYBE_CROSS_COMPILE}" ARCH="${TARGET_KARCH}" defconfig
if [ "${TARGET_KARCH}" = "x86_64" ]
then
make CROSS_COMPILE="${MAYBE_CROSS_COMPILE}" ARCH="${TARGET_KARCH}" xen.config
./scripts/config -e XEN_PV
./scripts/config -e XEN_PV_DOM0
if [ "${TARGET_KARCH}" = "x86_64" ]; then
make CROSS_COMPILE="${MAYBE_CROSS_COMPILE}" ARCH="${TARGET_KARCH}" xen.config
./scripts/config -e XEN_PV
./scripts/config -e XEN_PV_DOM0
fi
make CROSS_COMPILE="${MAYBE_CROSS_COMPILE}" ARCH="${TARGET_KARCH}" mod2yesconfig
@@ -45,5 +44,5 @@ make CROSS_COMPILE="${MAYBE_CROSS_COMPILE}" ARCH="${TARGET_KARCH}" mod2yesconfig
make "-j$(nproc)" CROSS_COMPILE="${MAYBE_CROSS_COMPILE}" ARCH="${TARGET_KARCH}"
[ -f "arch/x86/boot/bzImage" ] && cp "arch/x86/boot/bzImage" kernel.image
[ -f "arch/arm64/boot/Image.gz" ] && gzip -d < "arch/arm64/boot/Image.gz" > kernel.image
[ -f "arch/arm64/boot/Image.gz" ] && gzip -d <"arch/arm64/boot/Image.gz" >kernel.image
exit 0

View File

@@ -1,7 +1,7 @@
#!/bin/sh
#!/bin/bash
set -e
cd "$(dirname "${0}")/.." || exit 1
cargo fmt --all
shfmt -w hack/**/*.sh
cargo fmt --all || true
shfmt -w hack/**/*.sh || true

View File

@@ -1,5 +1,5 @@
use crate::context::SproutContext;
use anyhow::{Result, bail};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::rc::Rc;
@@ -19,7 +19,7 @@ pub mod splash;
/// that you can specify via other concepts.
///
/// Actions are the main work that Sprout gets done, like booting Linux.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ActionDeclaration {
/// Chainload to another EFI application.
/// This allows you to load any EFI application, either to boot an operating system
@@ -50,7 +50,10 @@ pub fn execute(context: Rc<SproutContext>, name: impl AsRef<str>) -> Result<()>
bail!("unknown action '{}'", name.as_ref());
};
// Finalize the context and freeze it.
let context = context.finalize().freeze();
let context = context
.finalize()
.context("unable to finalize context")?
.freeze();
// Execute the action.
if let Some(chainload) = &action.chainload {
@@ -61,6 +64,7 @@ pub fn execute(context: Rc<SproutContext>, name: impl AsRef<str>) -> Result<()>
return Ok(());
} else if let Some(edera) = &action.edera {
edera::edera(context.clone(), edera)?;
return Ok(());
}
#[cfg(feature = "splash")]

View File

@@ -10,7 +10,7 @@ use uefi::CString16;
use uefi::proto::loaded_image::LoadedImage;
/// The configuration of the chainload action.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ChainloadConfiguration {
/// The path to the image to chainload.
/// This can be a Linux EFI stub (vmlinuz usually) or a standard EFI executable.
@@ -99,10 +99,6 @@ pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfigurat
initrd_handle = Some(handle);
}
// Retrieve the base and size of the loaded image to display.
let (base, size) = loaded_image_protocol.info();
info!("loaded image: base={:#x} size={:#x}", base.addr(), size);
// Start the loaded image.
// This call might return, or it may pass full control to another image that will never return.
// Capture the result to ensure we can return an error if the image fails to start, but only

View File

@@ -22,7 +22,7 @@ use crate::{
/// The configuration of the edera action which boots the Edera hypervisor.
/// Edera is based on Xen but modified significantly with a Rust stack.
/// Sprout is a component of the Edera stack and provides the boot functionality of Xen.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct EderaConfiguration {
/// The path to the Xen hypervisor EFI image.
pub xen: String,

View File

@@ -1,10 +1,11 @@
use crate::context::SproutContext;
use anyhow::Result;
use log::info;
use serde::{Deserialize, Serialize};
use std::rc::Rc;
/// The configuration of the print action.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct PrintConfiguration {
/// The text to print to the console.
#[serde(default)]
@@ -13,6 +14,6 @@ pub struct PrintConfiguration {
/// Executes the print action with the specified `configuration` inside the provided `context`.
pub fn print(context: Rc<SproutContext>, configuration: &PrintConfiguration) -> Result<()> {
println!("{}", context.stamp(&configuration.text));
info!("{}", context.stamp(&configuration.text));
Ok(())
}

View File

@@ -15,7 +15,7 @@ use uefi::proto::console::gop::GraphicsOutput;
const DEFAULT_SPLASH_TIME: u32 = 0;
/// The configuration of the splash action.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct SplashConfiguration {
/// The path to the image to display.
/// Currently, only PNG images are supported.
@@ -52,6 +52,11 @@ fn fit_to_frame(image: &DynamicImage, frame: Rect) -> Rect {
height: image.height(),
};
// Handle the case where the image is zero-sized.
if input.height == 0 || input.width == 0 {
return input;
}
// Calculate the ratio of the image dimensions.
let input_ratio = input.width as f32 / input.height as f32;
@@ -66,6 +71,11 @@ fn fit_to_frame(image: &DynamicImage, frame: Rect) -> Rect {
height: frame.height,
};
// Handle the case where the output is zero-sized.
if output.height == 0 || output.width == 0 {
return output;
}
if input_ratio < frame_ratio {
output.width = (frame.height as f32 * input_ratio).floor() as u32;
output.height = frame.height;
@@ -110,7 +120,8 @@ fn draw(image: DynamicImage) -> Result<()> {
let image = resize_to_fit(&image, fit);
// Create a framebuffer to draw the image on.
let mut framebuffer = Framebuffer::new(width, height);
let mut framebuffer =
Framebuffer::new(width, height).context("unable to create framebuffer")?;
// Iterate over the pixels in the image and put them on the framebuffer.
for (x, y, pixel) in image.enumerate_pixels() {

129
src/autoconfigure.rs Normal file
View File

@@ -0,0 +1,129 @@
use crate::actions::ActionDeclaration;
use crate::actions::chainload::ChainloadConfiguration;
use crate::config::RootConfiguration;
use crate::entries::EntryDeclaration;
use crate::generators::GeneratorDeclaration;
use crate::generators::bls::BlsConfiguration;
use anyhow::{Context, Result};
use uefi::cstr16;
use uefi::fs::{FileSystem, Path};
use uefi::proto::device_path::DevicePath;
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
use uefi::proto::media::fs::SimpleFileSystem;
/// The name prefix of the BLS chainload action that will be used
/// by the BLS generator to chainload entries.
const BLS_CHAINLOAD_ACTION_PREFIX: &str = "bls-chainload-";
/// Scan the specified `filesystem` for BLS configurations.
fn scan_for_bls(
filesystem: &mut FileSystem,
root: &DevicePath,
config: &mut RootConfiguration,
) -> Result<bool> {
// BLS has a loader.conf file that can specify its own auto-entries mechanism.
let bls_loader_conf_path = Path::new(cstr16!("\\loader\\loader.conf"));
// BLS also has an entries directory that can specify explicit entries.
let bls_entries_path = Path::new(cstr16!("\\loader\\entries"));
// Convert the device path root to a string we can use in the configuration.
let mut root = root
.to_string(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert device root to string")?
.to_string();
// Add a trailing slash to the root to ensure the path is valid.
root.push('/');
// Whether we have a loader.conf file.
let has_loader_conf = filesystem
.try_exists(bls_loader_conf_path)
.context("unable to check for BLS loader.conf file")?;
// Whether we have an entries directory.
// We actually iterate the entries to see if there are any.
let has_entries_dir = filesystem
.read_dir(bls_entries_path)
.ok()
.and_then(|mut iterator| iterator.next())
.map(|entry| entry.is_ok())
.unwrap_or(false);
// Detect if a BLS supported configuration is on this filesystem.
// We check both loader.conf and entries directory as only one of them is required.
if !(has_loader_conf || has_entries_dir) {
return Ok(false);
}
// Generate a unique name for the BLS chainload action.
let chainload_action_name = format!("{}{}", BLS_CHAINLOAD_ACTION_PREFIX, root);
// BLS is now detected, generate a configuration for it.
let generator = BlsConfiguration {
entry: EntryDeclaration {
title: "$title".to_string(),
actions: vec![chainload_action_name.clone()],
..Default::default()
},
path: format!("{}\\loader", root),
};
// Generate a unique name for the BLS generator and insert the generator into the configuration.
config.generators.insert(
format!("autoconfigure-bls-{}", root),
GeneratorDeclaration {
bls: Some(generator),
..Default::default()
},
);
// Generate a chainload configuration for BLS.
// BLS will provide these values to us.
let chainload = ChainloadConfiguration {
path: format!("{}\\$chainload", root),
options: vec!["$options".to_string()],
linux_initrd: Some(format!("{}\\$initrd", root)),
};
// Insert the chainload action into the configuration.
config.actions.insert(
chainload_action_name,
ActionDeclaration {
chainload: Some(chainload),
..Default::default()
},
);
// We had a BLS supported configuration, so return true.
Ok(true)
}
/// Generate a [RootConfiguration] based on the environment.
/// Intakes a `config` to use as the basis of the autoconfiguration.
pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> {
// Find all the filesystems that are on the system.
let filesystem_handles =
uefi::boot::find_handles::<SimpleFileSystem>().context("unable to scan filesystems")?;
// For each filesystem that was detected, scan it for supported autoconfig mechanisms.
for handle in filesystem_handles {
// Acquire the device path root for the filesystem.
let root = {
uefi::boot::open_protocol_exclusive::<DevicePath>(handle)
.context("unable to get root for filesystem")?
.to_boxed()
};
// Open the filesystem that was detected.
let filesystem = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(handle)
.context("unable to open filesystem")?;
// Trade the filesystem protocol for the uefi filesystem helper.
let mut filesystem = FileSystem::new(filesystem);
// Scan the filesystem for BLS supported configurations.
// If we find any, we will add a BLS generator to the configuration.
scan_for_bls(&mut filesystem, &root, config).context("unable to scan filesystem")?;
}
Ok(())
}

View File

@@ -14,14 +14,20 @@ pub mod loader;
/// This must be incremented when the configuration breaks compatibility.
pub const LATEST_VERSION: u32 = 1;
/// The default timeout for the boot menu in seconds.
pub const DEFAULT_MENU_TIMEOUT_SECONDS: u64 = 10;
/// The Sprout configuration format.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct RootConfiguration {
/// The version of the configuration. This should always be declared
/// and be the latest version that is supported. If not specified, it is assumed
/// the configuration is the latest version.
#[serde(default = "latest_version")]
pub version: u32,
/// Default options for Sprout.
#[serde(default)]
pub defaults: DefaultsConfiguration,
/// Values to be inserted into the root sprout context.
#[serde(default)]
pub values: BTreeMap<String, String>,
@@ -59,6 +65,23 @@ pub struct RootConfiguration {
pub phases: PhasesConfiguration,
}
/// Default configuration for Sprout, used when the corresponding options are not specified.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct DefaultsConfiguration {
/// The entry to boot without showing the boot menu.
/// If not specified, a boot menu is shown.
pub entry: Option<String>,
/// The timeout of the boot menu.
#[serde(rename = "menu-timeout", default = "default_menu_timeout")]
pub menu_timeout: u64,
/// Enables autoconfiguration of Sprout based on the environment.
pub autoconfigure: bool,
}
fn latest_version() -> u32 {
LATEST_VERSION
}
fn default_menu_timeout() -> u64 {
DEFAULT_MENU_TIMEOUT_SECONDS
}

View File

@@ -1,11 +1,15 @@
use crate::actions::ActionDeclaration;
use crate::options::SproutOptions;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::{Result, bail};
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;
use uefi::proto::device_path::DevicePath;
/// The maximum number of iterations that can be performed in [SproutContext::finalize].
const CONTEXT_FINALIZE_ITERATION_LIMIT: usize = 100;
/// Declares a root context for Sprout.
/// This contains data that needs to be shared across Sprout.
#[derive(Default)]
@@ -79,6 +83,11 @@ impl SproutContext {
self.root.as_ref()
}
/// Access the root context to modify it, if possible.
pub fn root_mut(&mut self) -> Option<&mut RootContext> {
Rc::get_mut(&mut self.root)
}
/// Retrieve the value specified by `key` from this context or its parents.
/// Returns `None` if the value is not found.
pub fn get(&self, key: impl AsRef<str>) -> Option<&String> {
@@ -151,11 +160,20 @@ impl SproutContext {
/// Finalizes a context by producing a context with no parent that contains all the values
/// of all parent contexts merged. This makes it possible to ensure [SproutContext] has no
/// inheritance with other [SproutContext]s. It will still contain a [RootContext] however.
pub fn finalize(&self) -> SproutContext {
pub fn finalize(&self) -> Result<SproutContext> {
// Collect all the values from the context and its parents.
let mut current_values = self.all_values();
// To ensure that there is no possible infinite loop, we need to check
// the number of iterations. If it exceeds 100, we bail.
let mut iterations: usize = 0;
loop {
iterations += 1;
if iterations > CONTEXT_FINALIZE_ITERATION_LIMIT {
bail!("infinite loop detected in context finalization");
}
let mut did_change = false;
let mut values = BTreeMap::new();
for (key, value) in &current_values {
@@ -176,11 +194,11 @@ impl SproutContext {
}
// Produce the final context.
Self {
Ok(Self {
root: self.root.clone(),
parent: None,
values: current_values,
}
})
}
/// Stamps the `text` value with the specified `values` map. The returned value indicates
@@ -188,7 +206,25 @@ impl SproutContext {
fn stamp_values(values: &BTreeMap<String, String>, text: impl AsRef<str>) -> (bool, String) {
let mut result = text.as_ref().to_string();
let mut did_change = false;
for (key, value) in values {
// Sort the keys by length. This is to ensure that we stamp the longest keys first.
// If we did not do this, "$abc" could be stamped by "$a" into an invalid result.
let mut keys = values.keys().collect::<Vec<_>>();
// Sort by key length, reversed. This results in the longest keys appearing first.
keys.sort_by_key(|key| Reverse(key.len()));
for key in keys {
// Empty keys are not supported.
if key.is_empty() {
continue;
}
// We can fetch the value from the map. It is verifiable that the key exists.
let Some(value) = values.get(key) else {
unreachable!("keys iterated over is collected on a map that cannot be modified");
};
let next_result = result.replace(&format!("${key}"), value);
if result != next_result {
did_change = true;
@@ -204,4 +240,10 @@ impl SproutContext {
pub fn stamp(&self, text: impl AsRef<str>) -> String {
Self::stamp_values(&self.all_values(), text.as_ref()).1
}
/// Unloads a [SproutContext] back into an owned context. This
/// may not succeed if something else is holding onto the value.
pub fn unload(self: Rc<SproutContext>) -> Option<SproutContext> {
Rc::into_inner(self)
}
}

View File

@@ -12,7 +12,7 @@ use uefi::proto::device_path::LoadedImageDevicePath;
/// Drivers allow extending the functionality of Sprout.
/// Drivers are loaded at runtime and can provide extra functionality like filesystem support.
/// Drivers are loaded by their name, which is used to reference them in other concepts.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct DriverDeclaration {
/// The filesystem path to the driver.
/// This file should be an EFI executable that can be located and executed.

View File

@@ -7,7 +7,7 @@ use std::rc::Rc;
///
/// Entries are the user-facing concept of Sprout, making it possible
/// to run a set of actions with a specific context.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct EntryDeclaration {
/// The title of the entry which will be display in the boot menu.
/// This is the pre-stamped value.
@@ -27,6 +27,7 @@ pub struct BootableEntry {
title: String,
context: Rc<SproutContext>,
declaration: EntryDeclaration,
default: bool,
}
impl BootableEntry {
@@ -42,6 +43,7 @@ impl BootableEntry {
title,
context,
declaration,
default: false,
}
}
@@ -65,6 +67,11 @@ impl BootableEntry {
&self.declaration
}
/// Fetch whether the entry is the default entry.
pub fn is_default(&self) -> bool {
self.default
}
/// Swap out the context of the entry.
pub fn swap_context(&mut self, context: Rc<SproutContext>) {
self.context = context;
@@ -75,8 +82,30 @@ impl BootableEntry {
self.title = self.context.stamp(&self.title);
}
/// Mark this entry as the default entry.
pub fn mark_default(&mut self) {
self.default = true;
}
/// Prepend the name of the entry with `prefix`.
pub fn prepend_name_prefix(&mut self, prefix: &str) {
self.name.insert_str(0, prefix);
}
/// Determine if this entry matches `needle` by comparing to the name or title of the entry.
pub fn is_match(&self, needle: &str) -> bool {
self.name == needle || self.title == needle
}
/// Find an entry by `needle` inside the entry iterator `haystack`.
/// This will search for an entry by name, title, or index.
pub fn find<'a>(
needle: &str,
haystack: impl Iterator<Item = &'a BootableEntry>,
) -> Option<&'a BootableEntry> {
haystack
.enumerate()
.find(|(index, entry)| entry.is_match(needle) || index.to_string() == needle)
.map(|(_index, entry)| entry)
}
}

View File

@@ -10,7 +10,7 @@ pub mod filesystem_device_match;
/// Declares an extractor configuration.
/// Extractors allow calculating values at runtime
/// using built-in sprout modules.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ExtractorDeclaration {
/// The filesystem device match extractor.
/// This extractor finds a filesystem using some search criteria and returns

View File

@@ -20,7 +20,7 @@ use uefi_raw::Status;
///
/// This function only requires one of the criteria to match.
/// The fallback value can be used to provide a value if none is found.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct FilesystemDeviceMatchExtractor {
/// Matches a filesystem that has the specified label.
#[serde(default, rename = "has-label")]
@@ -81,7 +81,7 @@ pub fn extract(
} else {
// We should still handle other errors gracefully.
Err(error).context("unable to open filesystem partition info")?;
None
unreachable!()
}
}
}

View File

@@ -12,7 +12,7 @@ pub mod matrix;
/// Declares a generator configuration.
/// Generators allow generating entries at runtime based on a set of data.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct GeneratorDeclaration {
/// Matrix generator configuration.
/// Matrix allows you to specify multiple value-key values as arrays.

View File

@@ -6,25 +6,25 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::rc::Rc;
use std::str::FromStr;
use uefi::CString16;
use uefi::fs::{FileSystem, Path};
use uefi::cstr16;
use uefi::fs::{FileSystem, PathBuf};
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
use uefi::proto::media::fs::SimpleFileSystem;
/// BLS entry parser.
mod entry;
/// The default path to the BLS entries directory.
const BLS_TEMPLATE_PATH: &str = "\\loader\\entries";
/// The default path to the BLS directory.
const BLS_TEMPLATE_PATH: &str = "\\loader";
/// The configuration of the BLS generator.
/// The BLS uses the Bootloader Specification to produce
/// entries from an input template.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct BlsConfiguration {
/// The entry to use for as a template.
pub entry: EntryDeclaration,
/// The path to the BLS entries directory.
/// The path to the BLS directory.
#[serde(default = "default_bls_path")]
pub path: String,
}
@@ -45,30 +45,31 @@ fn quirk_initrd_remove_tuned(input: String) -> String {
pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Vec<BootableEntry>> {
let mut entries = Vec::new();
// Stamp the path to the BLS entries directory.
// Stamp the path to the BLS directory.
let path = context.stamp(&bls.path);
// Resolve the path to the BLS entries directory.
let resolved = utils::resolve_path(context.root().loaded_image_path()?, &path)
// Resolve the path to the BLS directory.
let bls_resolved = utils::resolve_path(context.root().loaded_image_path()?, &path)
.context("unable to resolve bls path")?;
// Construct a filesystem path to the BLS entries directory.
let mut entries_path = PathBuf::from(
bls_resolved
.sub_path
.to_string(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert bls path to string")?,
);
entries_path.push(cstr16!("entries"));
// Open exclusive access to the BLS filesystem.
let fs = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(resolved.filesystem_handle)
.context("unable to open bls filesystem")?;
let fs =
uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(bls_resolved.filesystem_handle)
.context("unable to open bls filesystem")?;
let mut fs = FileSystem::new(fs);
// Convert the subpath to the BLS entries directory to a string.
let sub_text_path = resolved
.sub_path
.to_string(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert subpath to string")?;
// Produce a path to the BLS entries directory.
let entries_path = Path::new(&sub_text_path);
// Read the BLS entries directory.
let entries_iter = fs
.read_dir(entries_path)
.read_dir(&entries_path)
.context("unable to read bls entries")?;
// For each entry in the BLS entries directory, parse the entry and add it to the list.
@@ -85,14 +86,13 @@ pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Ve
let name = entry.file_name().to_string();
// Ignore files that are not .conf files.
if !name.ends_with(".conf") {
if !name.to_lowercase().ends_with(".conf") {
continue;
}
// Produce the full path to the entry file.
let full_entry_path = CString16::try_from(format!("{}\\{}", sub_text_path, name).as_str())
.context("unable to construct full entry path")?;
let full_entry_path = Path::new(&full_entry_path);
// Create a mutable path so we can append the file name to produce the full path.
let mut full_entry_path = entries_path.to_path_buf();
full_entry_path.push(entry.file_name());
// Read the entry file.
let content = fs

View File

@@ -36,8 +36,13 @@ impl FromStr for BlsEntry {
// Trim the line.
let line = line.trim();
// Split the line once by a space.
let Some((key, value)) = line.split_once(" ") else {
// Skip over empty lines and comments.
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split the line once by whitespace.
let Some((key, value)) = line.split_once(char::is_whitespace) else {
continue;
};
@@ -99,7 +104,7 @@ impl BlsEntry {
self.linux
.clone()
.or(self.efi.clone())
.map(|path| path.replace("/", "\\").trim_start_matches("\\").to_string())
.map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string())
}
/// Fetches the path to an initrd to pass to the kernel, if any.
@@ -107,7 +112,7 @@ impl BlsEntry {
pub fn initrd_path(&self) -> Option<String> {
self.initrd
.clone()
.map(|path| path.replace("/", "\\").trim_start_matches("\\").to_string())
.map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string())
}
/// Fetches the options to pass to the kernel, if any.

View File

@@ -8,7 +8,7 @@ use std::rc::Rc;
/// Matrix generator configuration.
/// The matrix generator produces multiple entries based
/// on input values multiplicatively.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct MatrixConfiguration {
/// The template entry to use for each generated entry.
#[serde(default)]

View File

@@ -1,21 +1,26 @@
#![doc = include_str!("../README.md")]
#![feature(uefi_std)]
use crate::config::RootConfiguration;
use crate::context::{RootContext, SproutContext};
use crate::entries::BootableEntry;
use crate::options::SproutOptions;
use crate::options::parser::OptionsRepresentable;
use crate::phases::phase;
use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use log::info;
use std::collections::BTreeMap;
use std::ops::Deref;
use std::time::Duration;
use uefi::proto::device_path::LoadedImageDevicePath;
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
/// actions: Code that can be configured and executed by Sprout.
pub mod actions;
/// autoconfigure: Autoconfigure Sprout based on the detected environment.
pub mod autoconfigure;
/// config: Sprout configuration mechanism.
pub mod config;
@@ -34,6 +39,9 @@ pub mod extractors;
/// generators: Runtime code that can generate entries with specific values.
pub mod generators;
/// menu: Display a boot menu to select an entry to boot.
pub mod menu;
/// phases: Hooks into specific parts of the boot process.
pub mod phases;
@@ -56,10 +64,16 @@ fn main() -> Result<()> {
// 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(&options)?;
// If --autoconfigure is specified, we use a stub configuration.
let mut config = if options.autoconfigure {
info!("autoconfiguration enabled, configuration file will be ignored");
RootConfiguration::default()
} else {
// Load the configuration of sprout.
// At this point, the configuration has been validated and the specified
// version is checked to ensure compatibility.
config::loader::load(&options)?
};
// Load the root context.
// This is done in a block to ensure the release of the LoadedImageDevicePath protocol.
@@ -94,6 +108,31 @@ fn main() -> Result<()> {
// Load all configured drivers.
drivers::load(context.clone(), &config.drivers).context("unable to load drivers")?;
// If --autoconfigure is specified or the loaded configuration has autoconfigure enabled,
// trigger the autoconfiguration mechanism.
if context.root().options().autoconfigure || config.defaults.autoconfigure {
autoconfigure::autoconfigure(&mut config).context("unable to autoconfigure")?;
}
// Unload the context so that it can be modified.
let Some(mut context) = context.unload() else {
bail!("context safety violation while trying to unload context");
};
// Perform root context modification in a block to release the modification when complete.
{
// Modify the root context to include the autoconfigured actions.
let Some(root) = context.root_mut() else {
bail!("context safety violation while trying to modify root context");
};
// Extend the root context with the autoconfigured actions.
root.actions_mut().extend(config.actions);
}
// Refreeze the context to ensure that further operations can share the context.
let context = context.freeze();
// Run all the extractors declared in the configuration.
let mut extracted = BTreeMap::new();
for (name, extractor) in &config.extractors {
@@ -107,7 +146,7 @@ fn main() -> Result<()> {
context.insert(&extracted);
let context = context.freeze();
// Execute the late phase.
// Execute the startup phase.
phase(context.clone(), &config.phases.startup).context("unable to execute startup phase")?;
let mut entries = Vec::new();
@@ -143,36 +182,55 @@ fn main() -> Result<()> {
// Insert the values from the entry configuration into the
// sprout context to use with the entry itself.
context.insert(&entry.declaration().values);
let context = context.finalize().freeze();
let context = context
.finalize()
.context("unable to finalize context")?
.freeze();
// Provide the new context to the bootable entry.
entry.swap_context(context);
// Restamp the title with any values.
entry.restamp_title();
// Mark this entry as the default entry if it is declared as such.
if let Some(ref default_entry) = config.defaults.entry {
// If the entry matches the default entry, mark it as the default entry.
if entry.is_match(default_entry) {
entry.mark_default();
}
}
}
// TODO(azenla): Implement boot menu here.
// For now, we just print all of the entries.
info!("entries:");
for (index, entry) in entries.iter().enumerate() {
let title = entry.context().stamp(&entry.declaration().title);
info!(" entry {} [{}]: {}", index, entry.name(), title);
// If no entries were the default, pick the first entry as the default entry.
if entries.iter().all(|entry| !entry.is_default())
&& let Some(entry) = entries.first_mut()
{
entry.mark_default();
}
// Execute the late phase.
phase(context.clone(), &config.phases.late).context("unable to execute late phase")?;
// Use the boot option if possible, otherwise pick the first entry.
let entry = if let Some(ref boot) = context.root().options().boot {
entries
.iter()
.enumerate()
.find(|(index, entry)| {
entry.name() == boot || entry.title() == boot || &index.to_string() == boot
})
.context(format!("unable to find entry: {boot}"))?
.1 // select the bootable entry.
// If --boot is specified, boot that entry immediately.
let force_boot_entry = context.root().options().boot.as_ref();
// If --force-menu is specified, show the boot menu regardless of the value of --boot.
let force_boot_menu = context.root().options().force_menu;
// Determine the menu timeout in seconds based on the options or configuration.
// We prefer the options over the configuration to allow for overriding.
let menu_timeout = context
.root()
.options()
.menu_timeout
.unwrap_or(config.defaults.menu_timeout);
let menu_timeout = Duration::from_secs(menu_timeout);
// Use the forced boot entry if possible, otherwise pick the first entry using a boot menu.
let entry = if !force_boot_menu && let Some(ref force_boot_entry) = force_boot_entry {
BootableEntry::find(force_boot_entry, entries.iter())
.context(format!("unable to find entry: {force_boot_entry}"))?
} else {
entries.first().context("no entries found")?
// Delegate to the menu to select an entry to boot.
menu::select(menu_timeout, &entries).context("unable to select entry via boot menu")?
};
// Execute all the actions for the selected entry.

153
src/menu.rs Normal file
View File

@@ -0,0 +1,153 @@
use crate::entries::BootableEntry;
use anyhow::{Context, Result, bail};
use log::info;
use std::time::Duration;
use uefi::ResultExt;
use uefi::boot::TimerTrigger;
use uefi::proto::console::text::{Input, Key, ScanCode};
use uefi_raw::table::boot::{EventType, Tpl};
/// The characters that can be used to select an entry from keys.
const ENTRY_NUMBER_TABLE: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
/// Represents the operation that can be performed by the boot menu.
#[derive(PartialEq, Eq)]
enum MenuOperation {
/// The user selected a numbered entry.
Number(usize),
/// The user selected the escape key to exit the boot menu.
Exit,
/// The user selected the enter key to display the entries again.
Continue,
/// Timeout occurred.
Timeout,
/// No operation should be performed.
Nop,
}
/// Read a key from the input device with a duration, returning the [MenuOperation] that was
/// performed.
fn read(input: &mut Input, timeout: &Duration) -> Result<MenuOperation> {
// The event to wait for a key press.
let key_event = input
.wait_for_key_event()
.context("unable to acquire key event")?;
// Timer event for timeout.
// SAFETY: The timer event creation allocated a timer pointer on the UEFI heap.
// This is validated safe as long as we are in boot services.
let timer_event = unsafe {
uefi::boot::create_event_ex(EventType::TIMER, Tpl::CALLBACK, None, None, None)
.context("unable to create timer event")?
};
// The timeout is in increments of 100 nanoseconds.
let trigger = TimerTrigger::Relative(timeout.as_nanos() as u64 / 100);
uefi::boot::set_timer(&timer_event, trigger).context("unable to set timeout timer")?;
let mut events = [timer_event, key_event];
let event = uefi::boot::wait_for_event(&mut events)
.discard_errdata()
.context("unable to wait for event")?;
// The first event is the timer event.
// If it has triggered, the user did not select a numbered entry.
if event == 0 {
return Ok(MenuOperation::Timeout);
}
// If we reach here, there is a key event.
let Some(key) = input.read_key().context("unable to read key")? else {
bail!("no key was pressed");
};
match key {
Key::Printable(c) => {
// If the key is not ascii, we can't process it.
if !c.is_ascii() {
return Ok(MenuOperation::Continue);
}
// Convert the key to a char.
let c: char = c.into();
// Find the key pressed in the entry number table or continue.
Ok(ENTRY_NUMBER_TABLE
.iter()
.position(|&x| x == c)
.map(MenuOperation::Number)
.unwrap_or(MenuOperation::Continue))
}
// The escape key is used to exit the boot menu.
Key::Special(ScanCode::ESCAPE) => Ok(MenuOperation::Exit),
// If the special key is unknown, do nothing.
Key::Special(_) => Ok(MenuOperation::Nop),
}
}
/// Selects an entry from the list of entries using the boot menu.
fn select_with_input<'a>(
input: &mut Input,
timeout: Duration,
entries: &'a [BootableEntry],
) -> Result<&'a BootableEntry> {
loop {
// If the timeout is not zero, let's display the boot menu.
if !timeout.is_zero() {
// Until a pretty menu is available, we just print all the entries.
info!("Boot Menu:");
for (index, entry) in entries.iter().enumerate() {
let title = entry.context().stamp(&entry.declaration().title);
info!(" [{}] {} ({})", index, title, entry.name());
}
}
// Read from input until a valid operation is selected.
let operation = loop {
// If the timeout is zero, we can exit immediately because there is nothing to do.
if timeout.is_zero() {
break MenuOperation::Exit;
}
info!("Select a boot entry using the number keys.");
info!("Press Escape to exit and enter to display the entries again.");
let operation = read(input, &timeout)?;
if operation != MenuOperation::Nop {
break operation;
}
};
match operation {
// Entry was selected by number. If the number is invalid, we continue.
MenuOperation::Number(index) => {
let Some(entry) = entries.get(index) else {
println!("invalid entry number");
continue;
};
return Ok(entry);
}
// When the user exits the boot menu or a timeout occurs, we should
// boot the default entry, if any.
MenuOperation::Exit | MenuOperation::Timeout => {
return entries
.iter()
.find(|item| item.is_default())
.context("no default entry available");
}
// If the operation is to continue or nop, we can just run the loop again.
MenuOperation::Continue | MenuOperation::Nop => {
continue;
}
}
}
}
/// Shows a boot menu to select a bootable entry to boot.
/// The actual work is done internally in [select_with_input] which is called
/// within the context of the standard input device.
pub fn select(timeout: Duration, entries: &[BootableEntry]) -> Result<&BootableEntry> {
// Acquire the standard input device and run the boot menu.
uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries))
}

View File

@@ -11,18 +11,27 @@ const DEFAULT_CONFIG_PATH: &str = "\\sprout.toml";
/// The parsed options of sprout.
#[derive(Debug)]
pub struct SproutOptions {
/// Configures Sprout automatically based on the environment.
pub autoconfigure: bool,
/// Path to a configuration file to load.
pub config: String,
/// Entry to boot without showing the boot menu.
pub boot: Option<String>,
/// Force display of the boot menu.
pub force_menu: bool,
/// The timeout for the boot menu in seconds.
pub menu_timeout: Option<u64>,
}
/// The default Sprout options.
impl Default for SproutOptions {
fn default() -> Self {
Self {
autoconfigure: false,
config: DEFAULT_CONFIG_PATH.to_string(),
boot: None,
force_menu: false,
menu_timeout: None,
}
}
}
@@ -49,6 +58,20 @@ impl OptionsRepresentable for SproutOptions {
form: OptionForm::Value,
},
),
(
"force-menu",
OptionDescription {
description: "Force showing of the boot menu",
form: OptionForm::Flag,
},
),
(
"menu-timeout",
OptionDescription {
description: "Boot menu timeout, in seconds",
form: OptionForm::Value,
},
),
(
"help",
OptionDescription {
@@ -66,6 +89,11 @@ impl OptionsRepresentable for SproutOptions {
for (key, value) in options {
match key.as_str() {
"autoconfigure" => {
// Enable autoconfiguration.
result.autoconfigure = true;
}
"config" => {
// The configuration file to load.
result.config = value.context("--config option requires a value")?;
@@ -76,6 +104,20 @@ impl OptionsRepresentable for SproutOptions {
result.boot = Some(value.context("--boot option requires a value")?);
}
"force-menu" => {
// Force showing of the boot menu.
result.force_menu = true;
}
"menu-timeout" => {
// The timeout for the boot menu in seconds.
let value = value.context("--menu-timeout option requires a value")?;
let value = value
.parse::<u64>()
.context("menu-timeout must be a number")?;
result.menu_timeout = Some(value);
}
_ => bail!("unknown option: --{key}"),
}
}

View File

@@ -72,11 +72,7 @@ pub trait OptionsRepresentable {
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}");
};
if let Some((part_key, part_value)) = option.split_once('=') {
let part_key = part_key.to_string();
let part_value = part_value.to_string();
option = part_key;
@@ -131,7 +127,7 @@ pub trait OptionsRepresentable {
);
}
// Exit because the help has been displayed.
std::process::exit(1);
std::process::exit(0);
}
// Insert the option and the value into the map.

View File

@@ -7,7 +7,7 @@ use std::rc::Rc;
/// Configures the various phases of the boot process.
/// This allows hooking various phases to run actions.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct PhasesConfiguration {
/// The early phase is run before drivers are loaded.
#[serde(default)]
@@ -23,7 +23,7 @@ pub struct PhasesConfiguration {
/// Configures a single phase of the boot process.
/// There can be multiple phase configurations that are
/// executed sequentially.
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct PhaseConfiguration {
/// The actions to run when the phase is executed.
#[serde(default)]

View File

@@ -27,6 +27,12 @@ pub fn text_to_device_path(path: &str) -> Result<PoolDevicePath> {
.context("unable to convert text to device path")
}
/// Checks if a [CString16] contains a char `c`.
/// We need to call to_string() because CString16 doesn't support `contains` with a char.
fn cstring16_contains_char(string: &CString16, c: char) -> bool {
string.to_string().contains(c)
}
/// Grabs the root part of the `path`.
/// For example, given "PciRoot(0x0)/Pci(0x4,0x0)/NVMe(0x1,00-00-00-00-00-00-00-00)/HD(1,MBR,0xBE1AFDFA,0x3F,0xFBFC1)/\EFI\BOOT\BOOTX64.efi"
/// it will give "PciRoot(0x0)/Pci(0x4,0x0)/NVMe(0x1,00-00-00-00-00-00-00-00)/HD(1,MBR,0xBE1AFDFA,0x3F,0xFBFC1)"
@@ -37,7 +43,7 @@ pub fn device_path_root(path: &DevicePath) -> Result<String> {
let item = item.to_string(DisplayOnly(false), AllowShortcuts(false));
if item
.as_ref()
.map(|item| item.to_string().contains("("))
.map(|item| cstring16_contains_char(item, '('))
.unwrap_or(false)
{
Some(item.unwrap_or_default())
@@ -62,7 +68,7 @@ pub fn device_path_subpath(path: &DevicePath) -> Result<String> {
let item = item.to_string(DisplayOnly(false), AllowShortcuts(false));
if item
.as_ref()
.map(|item| item.to_string().contains("("))
.map(|item| cstring16_contains_char(item, '('))
.unwrap_or(false)
{
None
@@ -104,11 +110,11 @@ pub fn resolve_path(default_root_path: &DevicePath, input: &str) -> Result<Resol
it.to_string(DisplayOnly(false), AllowShortcuts(false))
.unwrap_or_default()
})
.map(|it| it.to_string().contains("("))
.map(|it| it.to_string().contains('('))
.unwrap_or(false);
if !path_has_device {
let mut input = input.to_string();
if !input.starts_with("\\") {
if !input.starts_with('\\') {
input.insert(0, '\\');
}
input.insert_str(

View File

@@ -13,17 +13,33 @@ pub struct Framebuffer {
impl Framebuffer {
/// Creates a new framebuffer of the specified `width` and `height`.
pub fn new(width: usize, height: usize) -> Self {
Framebuffer {
pub fn new(width: usize, height: usize) -> Result<Self> {
// Verify that the size is valid during multiplication.
let size = width
.checked_mul(height)
.context("framebuffer size overflow")?;
// Initialize the pixel buffer with black pixels, with the verified size.
let pixels = vec![BltPixel::new(0, 0, 0); size];
Ok(Framebuffer {
width,
height,
pixels: vec![BltPixel::new(0, 0, 0); width * height],
}
pixels,
})
}
/// Mutably acquires a pixel of the framebuffer at the specified `x` and `y` coordinate.
pub fn pixel(&mut self, x: usize, y: usize) -> Option<&mut BltPixel> {
self.pixels.get_mut(y * self.width + x)
// Verify that the coordinates are within the bounds of the framebuffer.
if x >= self.width || y >= self.height {
return None;
}
// Calculate the index of the pixel safely, returning None if it overflows.
let index = y.checked_mul(self.width)?.checked_add(x)?;
// Return the pixel at the index. If the index is out of bounds, this will return None.
self.pixels.get_mut(index)
}
/// Blit the framebuffer to the specified `gop` [GraphicsOutput].

View File

@@ -97,7 +97,7 @@ impl MediaLoaderHandle {
}
/// Creates a new device path for the media loader based on a vendor `guid`.
fn device_path(guid: Guid) -> Box<DevicePath> {
fn device_path(guid: Guid) -> Result<Box<DevicePath>> {
// The buffer for the device path.
let mut path = Vec::new();
// Build a device path for the media loader with a vendor-specific guid.
@@ -106,18 +106,18 @@ impl MediaLoaderHandle {
vendor_guid: guid,
vendor_defined_data: &[],
})
.unwrap() // We know that the device path is valid, so we can unwrap.
.context("unable to produce device path")?
.finalize()
.unwrap(); // We know that the device path is valid, so we can unwrap.
.context("unable to produce device path")?;
// Convert the device path to a boxed device path.
// This is safer than dealing with a pooled device path.
path.to_boxed()
Ok(path.to_boxed())
}
/// Checks if the media loader is already registered with the UEFI stack.
fn already_registered(guid: Guid) -> Result<bool> {
// Acquire the device path for the media loader.
let path = Self::device_path(guid);
let path = Self::device_path(guid)?;
let mut existing_path = path.as_ref();
@@ -142,7 +142,7 @@ impl MediaLoaderHandle {
/// to load the data from.
pub fn register(guid: Guid, data: Box<[u8]>) -> Result<MediaLoaderHandle> {
// Acquire the vendor device path for the media loader.
let path = Self::device_path(guid);
let path = Self::device_path(guid)?;
// Check if the media loader is already registered.
// If it is, we can't register it again safely.