Compare commits

...
55 Commits
Author SHA1 Message Date
alex 1cd9f67c48 sprout: version 0.0.30 (#103) 2026-09-26 21:17:37 -07:00
alex b39436cbe2 fix(menu): fall back to the basic menu if the simple menu fails
The simple menu failed, and boot was aborted, when the console couldn't
set the cursor position or colors or clear the screen, which some
serial consoles and minimal console drivers don't support. Use the
basic menu, which only prints text, when the simple menu fails.
2026-09-26 20:52:15 -07:00
alex 5fd2dbb4a7 docs(development): list every crate in the crate structure
The crate list left out edera-sprout-bls and edera-sprout-parsing, and
said edera-sprout-boot was "as" crates/boot instead of "at".
2026-09-26 20:52:15 -07:00
alex 22dde5d0ac docs(readme): link the Linux boot protocol roadmap item to its issue
The Linux boot protocol item linked to the multiboot2 issue (#7)
instead of its own issue (#8).
2026-09-26 20:52:15 -07:00
alex 660a2df454 docs(setup): fix step headings and the name of the downloaded file
The Ubuntu guide repeated the Step 7 heading, and the Windows guide went
from Step 1 to Step 3. The guides also said to download
sprout-<arch>.efi, then to copy the downloaded sprout.efi file, which
doesn't exist under that name.
2026-09-26 20:52:15 -07:00
alex 5427d621c5 docs(setup): describe the filesystem of /boot on openSUSE
The openSUSE guide said most Debian systems use ext4 for /boot, copied
from the Debian guide. openSUSE uses Btrfs by default, so say so and
point to the Btrfs driver, keeping ext4 as the example.
2026-09-26 20:52:15 -07:00
alex 3f0c124b22 docs(setup): install efifs on Alpine with apk add
apk has no install command, so the command in the Alpine guide failed.
2026-09-26 20:52:15 -07:00
alex ebad8342ee docs(setup): copy Fedora ARM64 GRUB modules to the arm64-efi directory
The ARM64 step copied the arm64-efi GRUB modules into
/boot/grub2/x86_64-efi, where GRUB on ARM64 doesn't look for them, so
insmod chain and insmod fat failed.
2026-09-26 20:52:15 -07:00
alex f293bc50a8 docs(setup): pass the disk and partition number to efibootmgr
The unsigned setup guides passed the EFI System Partition device to
efibootmgr -d, which expects the disk, and left out -p, which defaults
to partition 1. The command failed, or created a boot entry for the
wrong partition when the ESP isn't the first one. Pass the disk and the
partition number, as the signed guides do.
2026-09-26 20:52:15 -07:00
alex 4e08a078d9 docs(contributing): open work in progress as a draft pull request
GitHub doesn't mark a pull request as a draft when its title starts
with WIP:, so contributors following the guide got a normal pull
request. Say to open a draft pull request instead.
2026-09-26 20:52:15 -07:00
alex b67baf5a65 fix(github): remove the team from issue template assignees
Issue forms can only assign users, so assigning edera-dev/engineering
did nothing. Remove it rather than leave an assignment that never
happens.
2026-09-26 20:52:15 -07:00
alex c9586ff68d fix(hack): use the host CPU model for accelerated aarch64 VMs
The aarch64 dev VM always used -cpu cortex-a57, even with -accel hvf or
-accel kvm. QEMU rejects named CPU models with hvf, and with KVM unless
the host has that CPU, so booting natively on Apple Silicon or an
aarch64 Linux host with KVM failed. Use -cpu host when accelerated.
2026-09-26 20:52:15 -07:00
alex 592d7b41f6 fix(hack): don't override QEMU_ACCEL on macOS
On macOS, common.sh always set QEMU_ACCEL to hvf when it was available,
replacing any value the user set, like tcg. Only pick an accelerator
when QEMU_ACCEL isn't set, as the KVM check already does.
2026-09-26 20:52:15 -07:00
alex 406ad8dede fix(hack): format every shell script in hack
format.sh ran shfmt on hack/**/*.sh without enabling globstar, so ** only
matched a single directory. Scripts directly in hack and in deeper
directories like hack/dev/kernel were never formatted.
2026-09-26 20:52:15 -07:00
alex 2b8113f103 fix(hack): build the requested profile in the dev Docker build
The dev build passed RUST_TARGET_SUBDIR to the Docker build, but not
RUST_PROFILE, so the Dockerfile always built the release profile and
then copied from the directory of the requested profile. Any profile
other than release, like ./hack/dev/boot.sh x86_64 debug, failed. Pass
the profile as well.
2026-09-26 20:52:15 -07:00
alex 358cf92f65 fix(hack): point the dev Edera config at the kernel in the dev image
The dev Edera config loaded the kernel from \EFI\BOOT\kernel.efi, but
the dev image copies the kernel to \VMLINUZ, so booting with
SPROUT_CONFIG_NAME=edera always failed to read the kernel.
2026-09-26 20:52:15 -07:00
alex fe3de401ff fix(ci): don't cancel a release that is already running
Dispatching the release workflow again for the same tag cancelled the
running release, which could stop it in the middle of uploading assets
and leave the release with only some of them. Queue the new run behind
the running one instead.
2026-09-26 20:52:15 -07:00
alex 2e643d8081 fix(ci): cancel superseded pull request runs
The concurrency groups included the commit SHA, so every run was in a
group of its own and cancel-in-progress never cancelled a run that a
newer push had replaced. Group runs by pull request or ref instead.
Only pull request runs are cancelled, so every push to main still gets
fully checked and published.
2026-09-26 20:52:15 -07:00
alex cf1ea65414 fix(ci): don't give pull request builds attestation permissions
The publish job ran for pull requests with contents: write, id-token:
write, and attestations: write, while running build code from the pull
request. None of these were needed there: uploading artifacts doesn't
need contents: write, and attestation was already skipped for pull
requests. Build with read-only permissions, and attest in a separate
job that only runs on pushes to main.
2026-09-26 20:52:15 -07:00
alex 5b10fe9576 fix(ci): build releases from the release tag
The release workflow checked out the ref it was dispatched on, usually
main, instead of the release tag. The published binaries and their
provenance attestation could come from a different commit than the tag
they were attached to. Check out the tag instead.
2026-09-26 20:52:15 -07:00
alex 8f09c94857 docs(autoconfigure): correct the comment on Linux scan locations
The comment said symlinked scan locations are skipped, but nothing
checks for symlinks, and that the empty string is the root of the
filesystem, while the root location is "\\".
2026-09-26 20:52:15 -07:00
alex f6064d8751 fix(config): warn about unknown keys in the configuration
Keys that aren't part of the configuration were silently ignored, so a
misspelled key like linux_initrd instead of linux-initrd, or
menu_timeout instead of menu-timeout, quietly did nothing. Warn about
each ignored key with its full path while loading the configuration.

Also warn when an action or generator configures more than one kind,
as only the first one found is used.
2026-09-26 20:52:15 -07:00
alex 0d2eaa37c1 fix(media-loader): treat any non-zero boot policy as true
LoadFile2 compared the boot policy to Boolean::TRUE, which only matches
the value 1. UEFI treats any non-zero BOOLEAN as TRUE, so check it the
same way.
2026-09-26 20:52:15 -07:00
alex 9595fd53aa fix(shim): let the firmware decide on images the shim can't verify
The security hooks returned their own errors for calls the shim can't
verify, instead of passing them to the original firmware hooks. The
Security2 hook rejected a missing device path or file buffer, which are
optional in the PI specification, and a boot policy of TRUE. The
Security hook returned NOT_FOUND for any file it couldn't read, like
one in a firmware volume.

Pass these calls to the original hook, which is what the firmware does
without the hook installed. This matches systemd-boot, and the
firmware still enforces its own verification, so nothing is let
through that the firmware would reject.
2026-09-26 20:52:15 -07:00
alex 079bf0c3e8 fix(path): tell device and file path nodes apart by their type
Device path nodes were treated as device nodes when their text
contained "(", and as file path nodes otherwise. A file name with
parentheses, like \EFI\Linux\vmlinuz(1).efi, was then treated as a
device node, so it couldn't be resolved or loaded. Check whether each
node is a media file path node instead.
2026-09-26 20:52:15 -07:00
alex 8447861985 fix(chainload): unload the image if it fails before being started
If anything failed between loading the image and starting it, like
reading an initrd, registering the media loader, or setting the load
options, the loaded image was left in memory. Unload it in that case.
Once the image is started, the firmware unloads it when it exits.
2026-09-26 20:52:15 -07:00
alex 5dcfbdc8c5 fix(chainload): don't register an empty initrd
An initrd file, or chain of initrd files, with no contents was still
registered with the initrd media loader, which reports NOT_FOUND for
empty data. The Linux EFI stub treats anything other than
BUFFER_TOO_SMALL as a load error and fails to boot, rather than booting
without an initrd. Skip registering an empty initrd, as the edera
action already does.
2026-09-26 20:52:15 -07:00
alex a77803dfe8 fix(edera): stamp initrd paths before skipping empty ones
The edera action checked whether an initrd path was empty before
stamping it, so a path like "$initrd" that stamped to an empty value
was still read, which failed and aborted boot. Stamp the paths first,
and skip paths that stamp to the root of a filesystem, the same as the
chainload action.
2026-09-26 20:52:15 -07:00
alex e96a5a64c9 fix(initrd): pad each chained initrd to a 4-byte boundary
Chained initrds were concatenated back to back. Linux only finds a cpio
archive at a 4-byte aligned offset when it unpacks concatenated
initrds, so a compressed initramfs with an odd length followed by
another archive, like a microcode or overlay cpio, failed to unpack.
Pad each initrd with zeros to a 4-byte boundary, as GRUB and
systemd-boot do.
2026-09-26 20:52:15 -07:00
alex cd38f0271d fix(main): skip entries whose context can't be finalized
A value that refers to itself, such as a BLS entry with "options $options",
never stops changing when the context is finalized. After the iteration
limit, finalizing failed and every entry was lost, although the BLS
generator already skips broken entries. Skip just that entry with a
warning.

A value that refers to itself twice doubles in length every iteration,
so it could use up all memory before the limit was reached. Fail once a
value grows past 64 KiB.
2026-09-26 20:52:15 -07:00
alex aed9903180 fix(extractors): skip filesystems that can't be checked for a match
filesystem-device-match failed as soon as any filesystem couldn't be
checked, such as a removable device without media or a filesystem that
couldn't be opened exclusively. The fallback value was never used and
boot was aborted. Log a warning and check the next filesystem instead.

The filesystem protocol is now only opened when the label or item
criteria need it, and the partition UUIDs are parsed once up front.
2026-09-26 20:52:15 -07:00
alex 642236edec fix(autoconfigure): skip filesystems and directories that can't be scanned
Commit 2da1cea skipped filesystems that couldn't be opened, but an error
while scanning an opened filesystem, such as a driver returning
DEVICE_ERROR or ACCESS_DENIED, still aborted autoconfiguration for every
filesystem. Log a warning and move on to the next scan instead, and do
the same for each directory scanned for Linux kernels.
2026-09-26 20:52:15 -07:00
alex b4e765279b fix(autoconfigure): don't treat an empty BLS entries directory as BLS
BLS was detected when reading \loader\entries produced any item, but a
FAT directory always lists "." first, so an empty entries directory
counted as having entries. On an ESP with a leftover empty entries
directory, Linux autoconfiguration was skipped and no Linux entries
were shown. Only count the directory when it has a .conf file.
2026-09-26 20:52:15 -07:00
alex 133247c05a fix(autoconfigure): only pair kernels with an initramfs that is a file
The initramfs candidates were checked with try_exists, which also
accepts a directory, and fails the whole scan on any error other than
the file not being found. Only accept a regular file, and treat a
candidate that can't be checked as missing.
2026-09-26 20:52:15 -07:00
alex 9438c99c38 fix(autoconfigure): pair kernels with initramfs files ending in .img
Linux autoconfiguration only looked for an initramfs named with the
kernel suffix, like initrd.img-6.1 or initramfs-lts. Arch Linux
(initramfs-linux.img), and Fedora or Gentoo using dracut without BLS
(initramfs-<version>.img) add .img to the name, so their kernels were
booted without an initramfs and failed to mount the root filesystem.
Also try each candidate with .img added.
2026-09-26 20:52:15 -07:00
alex c54a597917 fix(bls): ignore a byte order mark at the start of an entry
A UTF-8 byte order mark isn't whitespace, so it became part of the first
key of the entry, and that line was ignored. Entries written by editors
that add one lost their title or kernel. Strip it before parsing.
2026-09-26 20:52:15 -07:00
alex c898a1499c fix(bls): combine the options of every options line
The Boot Loader Specification allows the options key to appear more
than once, with all of the options combined in order. Each options line
replaced the previous one instead, so an entry that set root= on one
line and other options on the next booted without its root filesystem.
2026-09-26 20:52:15 -07:00
alex 85f35a9a3e fix(config): make the default configuration match an empty one
RootConfiguration derived Default, which set menu-timeout to 0 and the
version to 0, while an empty configuration file gets a 10 second
timeout and the latest version. --autoconfigure uses the default
configuration, so it booted the first entry without showing the menu.
Implement Default with the same values serde uses.
2026-09-26 20:52:15 -07:00
alex ac69037111 fix(entries): stamp the sort key of entries
The sort-key of an entry was compared as written, without stamping the
values into it. Autoconfigured Linux entries all use "$kernel" as the
sort key, so they compared equal and kept the order the filesystem
listed them in, which made the default kernel arbitrary instead of the
newest one. Stamp the sort key along with the title.
2026-09-26 20:52:15 -07:00
alex 9fa57ee197 fix(bootloader-interface): don't fail boot when a variable can't be used
Every Boot Loader Interface variable that couldn't be read, written, or
removed aborted boot. The interface only exchanges information with the
operating system, so log a warning and continue instead. This includes
failing to remove LoaderEntryOneShot or LoaderConfigTimeoutOneShot,
which made every following boot fail the same way, as the variable was
never removed.
2026-09-26 20:52:15 -07:00
alex 8fce1738f2 fix(main): use the boot menu when the entry to boot can't be found
An entry from --boot or LoaderEntryOneShot that didn't match any entry
aborted boot, which happens when the one-shot entry was removed or
misspelled. Log a warning and use the boot menu instead.
2026-09-26 20:52:15 -07:00
alex 20ca279fca fix(main): ignore a LoaderEntryDefault that matches no entry
When LoaderEntryDefault was set, every entry that didn't match it was
unmarked as the default, including the one set by default-entry. If the
variable named an entry that no longer exists, such as a removed kernel,
the configured default was lost and the first entry booted instead.
Only apply the variable when some entry matches it.
2026-09-26 20:52:15 -07:00
alex 2b5cc4e70f fix(bootloader-interface): show the menu for LoaderConfigTimeoutOneShot
LoaderConfigTimeoutOneShot asks the boot loader to show the menu on the
next boot, and a value of 0 means the menu waits for the user. Sprout
treated it like LoaderConfigTimeout, so 0 hid the menu, the opposite of
what systemctl reboot --boot-loader-menu=0 asks for, and other values
only changed the timeout without forcing the menu.

Force the menu when the one-shot timeout is set, waiting for the user
when it is 0.
2026-09-26 20:52:15 -07:00
alex e8e3361764 fix(menu): show the boot menu when it is forced
--force-menu and LoaderConfigTimeout=menu-force only stopped --boot and
LoaderEntryOneShot from skipping the menu. The timeout was left as is,
so with a zero timeout the menu booted the default entry without being
drawn, and menu-force counted down and booted the default entry, where
the Boot Loader Interface says it should wait for the user.

Menus now take an optional timeout. menu-force waits for the user, and
--force-menu keeps the configured timeout unless it is zero, in which
case it waits for the user too.
2026-09-26 20:52:15 -07:00
alex df0bda04c0 fix(menu): let the basic menu select entries numbered 10 and above
The basic menu listed entries as [10], [11] and so on, but only read a
single digit, so those entries could never be selected.

Keep reading digits while another digit could still select a different
entry, and boot once it can't or when enter is pressed. With ten
entries or fewer, a single digit boots the entry right away as before.
2026-09-26 20:52:15 -07:00
alex 9e595bf8d5 fix(menu): stop the basic menu countdown on a key press
Every key that didn't select an entry, like enter or an arrow key, made
the basic menu wait for the full timeout again. Stop the countdown on
any key instead, like the simple menu, so the menu waits for the user
once they start using it.
2026-09-26 20:52:15 -07:00
alex 78bcaa79ff fix(menu): keep the countdown going after a spurious key event
Some firmware signals the key event without a key being available. The
simple menu then skipped the countdown for that tick, and the basic menu
started waiting for the full timeout again, so firmware that kept
signaling the key event would never boot the default entry.

Read the key inside the wait, and when no key is available keep waiting
on the same timer.
2026-09-26 20:52:15 -07:00
alex 11736ccabb fix(menu): don't panic on very large timeouts
The basic menu set a single timer for the whole timeout, and converting
a timeout of more than about 1.8e12 seconds into 100ns timer units
panics. The timeout can come from --menu-timeout or from
LoaderConfigTimeout, which persists, so the panic would repeat on every
boot. Limit a single timer to u32::MAX seconds.
2026-09-26 20:52:15 -07:00
alex 98f1a2c8c5 fix(tpm): treat a full event log as a successful measurement
The TCG2 specification says HashLogExtendEvent returns VOLUME_FULL when
the PCR was extended but the event log had no room for the event. This
was treated as a failure, which aborted loading the configuration and
booting Edera. Log a warning and continue instead.
2026-09-26 20:52:15 -07:00
alex 3490ec24f2 fix(tpm): don't fail boot when the active PCR banks can't be read
Any error opening the TCG2 protocol, reading its capability, or calling
GetActivePcrBanks aborted boot, although the value is only reported in
LoaderTpm2ActivePcrBanks. TPM firmware bugs are common, so log a
warning and report zero banks instead, as systemd-boot does.
2026-09-26 20:52:15 -07:00
alex 69fd8a0064 fix(timer): don't panic when the timer frequency is zero
PlatformTimer::start panicked if the timer frequency was zero. On
aarch64 the frequency comes from CNTFRQ_EL0, which some firmware leaves
unprogrammed, and on x86_64 a Stall that returns immediately measures
zero. The timer is only used for bootloader interface timestamps, so
log a warning and report zero durations instead.
2026-09-26 20:52:15 -07:00
alex afbba2c6d3 fix(options): ignore arguments that can't be parsed
An unknown option, an unexpected positional argument, or an invalid
option value made Sprout exit with ABORTED, so a typo in a boot entry or
junk passed by the firmware made the machine unbootable.

Log a warning and drop the argument the parser rejected, then parse the
rest again. Invalid --menu-timeout and --menu-style values are ignored
with a warning too, which also replaces the misleading "invalid integer"
error for an unknown menu style.
2026-09-26 20:52:15 -07:00
alex 8b94ebe5bd fix(env): decode load options that aren't a clean UCS-2 string
Load options were decoded with CStr16::from_u16_with_nul, which needs an
even byte count and exactly one null at the very end. Firmware that
passed an odd size, left off the null terminator, or padded the string
with extra nulls made argument parsing fail, and Sprout refused to boot.

Decode the raw bytes as little-endian UTF-16 up to the first null
instead, replacing anything that isn't valid.
2026-09-26 20:52:15 -07:00
alex dd8ae14da8 feat(initrd): support chaining multiple initrds (#101)
Add linux-initrd-chain to the chainload action and initrd-chain to the
edera action. Each file in the list is read in order and concatenated
into a single initrd. The chain can't be combined with the single
initrd option on the same action.

BLS entries with multiple initrds, either as repeated initrd lines or
as multiple paths on one line, previously only loaded the last one.
Parse all of them and set them in the initrd-0 through initrd-7 slots,
which the autoconfigured BLS action now chains. Unused slots stamp to
the filesystem root and are skipped. Entries with more than 8 initrds
are skipped with a warning.
2026-09-26 19:45:47 -07:00
alex 7b62d414ed feat(menu): add a simple boot menu similar to systemd-boot (#100)
The boot menu is now a BootMenu trait with two styles. The basic menu is
the existing one that prints entries and selects them by number. The new
simple menu is full-screen: entries are centered and highlighted, the
arrow keys, home, end, page up and page down move the selection, enter
boots it, and any key stops the countdown.

The style is picked with --menu-style or menu-style in [options], and
defaults to simple. The timer code used to wait for a key is shared
between both menus in menu.rs.
2026-09-26 19:31:50 -07:00
53 changed files with 1730 additions and 625 deletions

No files matched your search

-2
View File
@@ -3,8 +3,6 @@ description: File a bug report.
title: "bug: "
labels: ["bug", "triage"]
type: bug
assignees:
- edera-dev/engineering
body:
- type: markdown
attributes:
@@ -3,8 +3,6 @@ description: Request a feature.
title: "want: "
labels: ["enhancement", "triage"]
type: feature
assignees:
- edera-dev/engineering
body:
- type: markdown
attributes:
+3 -2
View File
@@ -12,8 +12,9 @@ permissions:
contents: read # Needed to checkout the repository.
concurrency:
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.sha }}"
cancel-in-progress: true
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}"
# Only cancel superseded pull request runs, so every push to main is fully checked.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
zizmor:
+3 -2
View File
@@ -12,8 +12,9 @@ permissions:
contents: read # Needed to checkout the repository.
concurrency:
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.sha }}"
cancel-in-progress: true
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}"
# Only cancel superseded pull request runs, so every push to main is fully checked.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
rustfmt:
+3 -2
View File
@@ -14,8 +14,9 @@ permissions:
contents: read # Needed to checkout the repository.
concurrency:
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.sha }}"
cancel-in-progress: true
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}"
# Only cancel superseded pull request runs, so every push to main is fully checked.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
+23 -7
View File
@@ -12,17 +12,18 @@ permissions:
contents: read # Needed to checkout the repository.
concurrency:
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.sha }}"
cancel-in-progress: true
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}"
# Only cancel superseded pull request runs, so every push to main is fully checked.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
artifacts:
name: artifacts
permissions:
contents: write # Needed to upload artifacts.
id-token: write # Needed for attestation.
attestations: write # Needed for attestations.
contents: read # Needed to checkout the repository.
runs-on: ubuntu-latest
outputs:
artifact-digest: "${{ steps.upload.outputs.artifact-digest }}"
steps:
- name: harden runner
uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1
@@ -48,9 +49,24 @@ jobs:
name: artifacts
path: target/assemble/*
# Attestation runs in its own job so that pull requests, which run the build with
# code from the pull request, never get the permissions needed to attest.
attest:
name: attest
needs: artifacts
if: github.event_name != 'pull_request'
permissions:
id-token: write # Needed for attestation.
attestations: write # Needed for attestations.
runs-on: ubuntu-latest
steps:
- name: harden runner
uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1
with:
egress-policy: audit
- name: 'attest artifacts'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: artifacts.zip
subject-digest: "sha256:${{ steps.upload.outputs.artifact-digest }}"
if: github.event_name != 'pull_request'
subject-digest: "sha256:${{ needs.artifacts.outputs.artifact-digest }}"
+4 -1
View File
@@ -13,7 +13,8 @@ permissions:
concurrency:
group: "${{ github.workflow }}-${{ github.event.inputs.release-tag }}"
cancel-in-progress: true
# Let a running release finish, so its assets are never left partially uploaded.
cancel-in-progress: false
jobs:
release:
@@ -32,6 +33,8 @@ jobs:
- name: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Build the tagged commit, not whichever ref the workflow was dispatched on.
ref: "refs/tags/${{ github.event.inputs.release-tag }}"
persist-credentials: false
- name: 'install rust toolchain'
+1 -1
View File
@@ -21,7 +21,7 @@ We encourage opening an issue on GitHub to report bugs.
1. For anything more than simple bug/doc fixes, please open a GitHub issue for tracking purposes.
2. Discuss the change with the teams to ensure we have consensus on the change being welcome.
3. We encourage opening the PR sooner than later, and prefixing with `WIP:` so GitHub labels it as a Draft.
3. We encourage opening the PR sooner than later, as a draft pull request.
4. Please include a detailed list of changes that the PR makes.
5. Once the PR is ready for review, remove the Draft status, and request a review from `edera-dev/engineering`.
6. After the review cycle concludes, and we know you are ready for merging, a team member will submit the PR to the merge queue.
Generated
+17 -6
View File
@@ -65,14 +65,14 @@ dependencies = [
[[package]]
name = "edera-sprout-bls"
version = "0.0.29"
version = "0.0.30"
dependencies = [
"anyhow",
]
[[package]]
name = "edera-sprout-boot"
version = "0.0.29"
version = "0.0.30"
dependencies = [
"anyhow",
"edera-sprout-bls",
@@ -83,6 +83,7 @@ dependencies = [
"hex",
"jaarg",
"log",
"serde_ignored",
"sha2",
"toml",
"uefi",
@@ -91,18 +92,18 @@ dependencies = [
[[package]]
name = "edera-sprout-build"
version = "0.0.29"
version = "0.0.30"
[[package]]
name = "edera-sprout-config"
version = "0.0.29"
version = "0.0.30"
dependencies = [
"serde",
]
[[package]]
name = "edera-sprout-eficore"
version = "0.0.29"
version = "0.0.30"
dependencies = [
"anyhow",
"bitflags",
@@ -115,7 +116,7 @@ dependencies = [
[[package]]
name = "edera-sprout-parsing"
version = "0.0.29"
version = "0.0.30"
dependencies = [
"hex",
"sha2",
@@ -237,6 +238,16 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
+5 -1
View File
@@ -11,7 +11,7 @@ resolver = "3"
[workspace.package]
license = "Apache-2.0"
version = "0.0.29"
version = "0.0.30"
homepage = "https://sprout.edera.dev"
repository = "https://github.com/edera-dev/sprout"
edition = "2024"
@@ -41,6 +41,10 @@ version = "1.0.229"
default-features = false
features = ["alloc", "derive"]
[workspace.dependencies.serde_ignored]
version = "0.1.14"
default-features = false
[workspace.dependencies.sha2]
version = "0.11.0"
default-features = false
+3 -1
View File
@@ -15,10 +15,12 @@ as an argument to boot.sh to boot the specified architecture.
Sprout is split into multiple crates:
- `edera-sprout-boot` as `crates/boot`: Bootloader entrypoint for Sprout.
- `edera-sprout-boot` at `crates/boot`: Bootloader entrypoint for Sprout.
- `edera-sprout-bls` at `crates/bls`: Parsing and sorting of Boot Loader Specification entries.
- `edera-sprout-build` at `crates/build`: Build logic for Sprout.
- `edera-sprout-config` at `crates/config`: Serialization structures for the Sprout configuration file.
- `edera-sprout-eficore` at `crates/eficore`: Core library for Sprout EFI code.
- `edera-sprout-parsing` at `crates/parsing`: Value stamping and other parsing helpers for Sprout.
It is intended that overtime Sprout will be split into even more crates.
+3 -1
View File
@@ -85,7 +85,7 @@ We recommend running Sprout without Secure Boot for development, and with Secure
- [ ] [Full-featured boot menu](https://github.com/edera-dev/sprout/issues/1)
- [ ] [UKI support](https://github.com/edera-dev/sprout/issues/6): partial
- [ ] [multiboot2 support](https://github.com/edera-dev/sprout/issues/7)
- [ ] [Linux boot protocol (boot without EFI stub)](https://github.com/edera-dev/sprout/issues/7)
- [ ] [Linux boot protocol (boot without EFI stub)](https://github.com/edera-dev/sprout/issues/8)
## Concepts
@@ -120,6 +120,8 @@ $ sprout.efi --config=\path\to\config.toml
$ sprout.efi --boot="Boot Xen"
# Autoconfigure Sprout, without loading a configuration file.
$ sprout.efi --autoconfigure
# Use the basic boot menu instead of the simple one.
$ sprout.efi --menu-style=basic
```
### Boot Linux from ESP
+59 -17
View File
@@ -2,6 +2,7 @@
extern crate alloc;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use anyhow::{Error, Result};
use core::{cmp::Ordering, iter::Peekable, str::FromStr};
@@ -15,8 +16,8 @@ pub struct BlsEntry {
pub options: Option<String>,
/// The path to the linux kernel.
pub linux: Option<String>,
/// The path to the initrd.
pub initrd: Option<String>,
/// The paths to the initrds, in the order they appear.
pub initrd: Vec<String>,
/// The path to an EFI image.
pub efi: Option<String>,
/// The sort key for the entry.
@@ -38,12 +39,16 @@ impl FromStr for BlsEntry {
let mut title: Option<String> = None;
let mut options: Option<String> = None;
let mut linux: Option<String> = None;
let mut initrd: Option<String> = None;
let mut initrd: Vec<String> = Vec::new();
let mut efi: Option<String> = None;
let mut sort_key: Option<String> = None;
let mut version: Option<String> = None;
let mut machine_id: Option<String> = None;
// Some editors write a UTF-8 byte order mark at the start of the file.
// It isn't whitespace, so it would otherwise be part of the first key.
let input = input.strip_prefix('\u{feff}').unwrap_or(input);
// Iterate over each line in the input and parse it.
for line in input.lines() {
let line = line.trim();
@@ -65,9 +70,17 @@ impl FromStr for BlsEntry {
title = Some(value.trim().to_string());
}
// The options to pass to the entry.
// The options to pass to the entry. The options key can appear more than once,
// and all the options are combined in the order they appear.
"options" => {
options = Some(value.trim().to_string());
let value = value.trim();
match options.as_mut() {
Some(options) => {
options.push(' ');
options.push_str(value);
}
None => options = Some(value.to_string()),
}
}
// The path to the linux kernel.
@@ -75,9 +88,10 @@ impl FromStr for BlsEntry {
linux = Some(value.trim().to_string());
}
// The path to the initrd.
// The path to an initrd. An entry can have multiple initrds, either as
// repeated initrd lines or as multiple paths separated by whitespace.
"initrd" => {
initrd = Some(value.trim().to_string());
initrd.extend(value.split_whitespace().map(|path| path.to_string()));
}
// The path to an EFI image.
@@ -134,12 +148,13 @@ impl BlsEntry {
.map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string())
}
/// Fetches the path to an initrd to pass to the kernel, if any.
/// Fetches the paths to the initrds to pass to the kernel, in order.
/// It also converts / to \\ to match EFI path style.
pub fn initrd_path(&self) -> Option<String> {
pub fn initrd_paths(&self) -> Vec<String> {
self.initrd
.clone()
.iter()
.map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string())
.collect()
}
/// Fetches the options to pass to the kernel, if any.
@@ -431,7 +446,7 @@ mod tests {
assert!(entry.title.is_none());
assert!(entry.linux.is_none());
assert!(entry.efi.is_none());
assert!(entry.initrd.is_none());
assert!(entry.initrd.is_empty());
assert!(entry.options.is_none());
assert!(entry.sort_key.is_none());
assert!(entry.version.is_none());
@@ -455,7 +470,7 @@ efi /EFI/fedora/shimx64.efi
assert_eq!(entry.version.as_deref(), Some("6.5.6-300.fc39.x86_64"));
assert_eq!(entry.machine_id.as_deref(), Some("abc123def456"));
assert_eq!(entry.linux.as_deref(), Some("/boot/vmlinuz-6.5.6"));
assert_eq!(entry.initrd.as_deref(), Some("/boot/initrd-6.5.6.img"));
assert_eq!(entry.initrd, ["/boot/initrd-6.5.6.img"]);
assert_eq!(entry.options.as_deref(), Some("root=/dev/sda1 ro quiet"));
assert_eq!(entry.sort_key.as_deref(), Some("fedora"));
assert_eq!(entry.efi.as_deref(), Some("/EFI/fedora/shimx64.efi"));
@@ -555,15 +570,42 @@ efi /EFI/fedora/shimx64.efi
}
#[test]
fn initrd_path_normalises_slashes() {
let entry: BlsEntry = "linux /vmlinuz\ninitrd /boot/initrd.img\n".parse().unwrap();
assert_eq!(entry.initrd_path().as_deref(), Some("boot\\initrd.img"));
fn parse_multiple_initrd_lines_in_order() {
let input = "linux /vmlinuz\ninitrd /intel-ucode.img\ninitrd /initramfs.img\n";
let entry: BlsEntry = input.parse().unwrap();
assert_eq!(entry.initrd, ["/intel-ucode.img", "/initramfs.img"]);
}
#[test]
fn initrd_path_none_when_not_set() {
fn parse_multiple_initrds_on_one_line() {
let input = "linux /vmlinuz\ninitrd /intel-ucode.img /initramfs.img\n";
let entry: BlsEntry = input.parse().unwrap();
assert_eq!(entry.initrd, ["/intel-ucode.img", "/initramfs.img"]);
}
#[test]
fn parse_multiple_options_lines_combined_in_order() {
let input = "linux /vmlinuz\noptions root=/dev/sda1 ro\noptions quiet\n";
let entry: BlsEntry = input.parse().unwrap();
assert_eq!(entry.options().as_deref(), Some("root=/dev/sda1 ro quiet"));
}
#[test]
fn parse_ignores_byte_order_mark() {
let entry: BlsEntry = "\u{feff}title Linux\nlinux /vmlinuz\n".parse().unwrap();
assert_eq!(entry.title().as_deref(), Some("Linux"));
}
#[test]
fn initrd_paths_normalises_slashes() {
let entry: BlsEntry = "linux /vmlinuz\ninitrd /boot/initrd.img\n".parse().unwrap();
assert_eq!(entry.initrd_paths(), ["boot\\initrd.img"]);
}
#[test]
fn initrd_paths_empty_when_not_set() {
let entry: BlsEntry = "linux /vmlinuz\n".parse().unwrap();
assert!(entry.initrd_path().is_none());
assert!(entry.initrd_paths().is_empty());
}
#[test]
+1
View File
@@ -15,6 +15,7 @@ edera-sprout-bls.path = "../bls"
edera-sprout-parsing.path = "../parsing"
hex.workspace = true
jaarg.workspace = true
serde_ignored.workspace = true
sha2.workspace = true
toml.workspace = true
log.workspace = true
+84 -31
View File
@@ -2,19 +2,59 @@ use crate::context::SproutContext;
use crate::phases::before_handoff;
use alloc::boxed::Box;
use alloc::rc::Rc;
use alloc::vec::Vec;
use anyhow::{Context, Result, bail};
use edera_sprout_config::actions::chainload::ChainloadConfiguration;
use edera_sprout_parsing::{combine_options, empty_is_none};
use edera_sprout_parsing::{append_initrd, combine_options, empty_is_none};
use eficore::bootloader_interface::BootloaderInterface;
use eficore::loader::source::ImageSource;
use eficore::loader::{ImageLoadRequest, ImageLoader};
use eficore::media_loader::MediaLoaderHandle;
use eficore::media_loader::constants::linux::LINUX_EFI_INITRD_MEDIA_GUID;
use uefi::CString16;
use log::warn;
use uefi::proto::loaded_image::LoadedImage;
use uefi::{CString16, Handle};
/// Read the initrd at the stamped `path` relative to the sprout image.
/// Provides [None] if the path refers to the root of a filesystem rather than a file.
pub fn read_initrd(context: &Rc<SproutContext>, path: &str) -> Result<Option<Vec<u8>>> {
let resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), path)
.context("unable to resolve initrd path")?;
// A path without a file component refers to the root of the filesystem, not an initrd.
// This happens when a path template like "$root\\$initrd-0" is stamped with an empty
// initrd value, such as a BLS entry without an initrd or an unused BLS initrd slot.
let subpath = eficore::path::device_path_subpath(&resolved.full_path)
.context("unable to get initrd subpath")?;
if subpath.trim_matches('\\').is_empty() {
return Ok(None);
}
let content = resolved.read_file().context("unable to read initrd")?;
Ok(Some(content))
}
/// 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>);
impl Drop for UnloadGuard {
fn drop(&mut self) {
if let Some(handle) = self.0.take()
&& let Err(error) = uefi::boot::unload_image(handle)
{
warn!("unable to unload image: {}", error);
}
}
}
/// Executes the chainload action using the specified `configuration` inside the provided `context`.
pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfiguration) -> Result<()> {
// The initrd can be provided as either a single initrd or a chain, but not both.
if configuration.linux_initrd.is_some() && !configuration.linux_initrd_chain.is_empty() {
bail!("linux-initrd and linux-initrd-chain cannot be used together");
}
// Retrieve the current image handle of sprout.
let sprout_image = uefi::boot::image_handle();
@@ -32,6 +72,9 @@ pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfigurat
// It will determine if the image needs to be loaded via the shim or can be loaded directly.
let image = ImageLoader::load(request)?;
// Unload the image if an error occurs before it is started.
let mut unload_guard = UnloadGuard(Some(*image.handle()));
// Stamp and combine the options to pass to the image.
let options = combine_options(context.stamp_iter(configuration.options.iter()));
@@ -66,47 +109,57 @@ pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfigurat
}
}
// Stamp the initrd path, if provided.
let initrd = configuration
// Stamp the initrd paths. A single linux-initrd is read the same way as a chain of one.
let initrd_paths = configuration
.linux_initrd
.as_ref()
.iter()
.chain(configuration.linux_initrd_chain.iter())
.map(|item| context.stamp(item));
// The initrd can be None or empty, so we need to collapse that into a single Option.
let initrd = empty_is_none(initrd);
// If an initrd is provided, register it with the EFI stack.
let mut initrd_handle = None;
if let Some(linux_initrd) = initrd {
let resolved_initrd =
eficore::path::resolve_path(Some(context.root().loaded_image_path()?), &linux_initrd)
.context("unable to resolve linux initrd path")?;
// A path without a file component refers to the root of the filesystem, not an initrd.
// This happens when a path template like "$root\\$initrd" is stamped with an empty
// initrd value, such as a BLS entry without an initrd.
let initrd_subpath = eficore::path::device_path_subpath(&resolved_initrd.full_path)
.context("unable to get linux initrd subpath")?;
if !initrd_subpath.trim_matches('\\').is_empty() {
let content = resolved_initrd
.read_file()
.context("unable to read linux initrd")?;
let handle = MediaLoaderHandle::register(
LINUX_EFI_INITRD_MEDIA_GUID,
content.into_boxed_slice(),
)
.context("unable to register linux initrd")?;
initrd_handle = Some(handle);
// Read each initrd and concatenate the contents in order.
// Paths that are empty after stamping are skipped.
let mut initrd: Option<Vec<u8>> = None;
for path in initrd_paths {
let Some(path) = empty_is_none(Some(path)) else {
continue;
};
let Some(content) = read_initrd(&context, &path)? else {
continue;
};
match initrd.as_mut() {
Some(initrd) => append_initrd(initrd, &content),
None => initrd = Some(content),
}
}
// If an initrd was read, register it with the EFI stack.
// An empty initrd is not registered, as Linux fails to boot if the initrd it is
// given can't be loaded, instead of booting without one.
let mut initrd_handle = None;
if let Some(content) = initrd.filter(|content| !content.is_empty()) {
let handle =
MediaLoaderHandle::register(LINUX_EFI_INITRD_MEDIA_GUID, content.into_boxed_slice())
.context("unable to register linux initrd")?;
initrd_handle = Some(handle);
}
// Mark execution of an entry in the bootloader interface.
BootloaderInterface::mark_exec(context.root().timer())
.context("unable to mark execution of boot entry in bootloader interface")?;
// This is only informational, so it should not prevent booting.
if let Err(error) = BootloaderInterface::mark_exec(context.root().timer()) {
warn!(
"unable to mark execution of boot entry in bootloader interface: {:#}",
error
);
}
// Since we are about to hand off control to another image, we need to execute the handoff hook.
// This will perform operations like clearing the screen.
before_handoff(&context).context("unable to execute before handoff hook")?;
// The image is about to be started, so it is no longer ours to unload.
// Once started, the image is unloaded by the firmware when it exits.
unload_guard.0 = None;
// 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
+29 -8
View File
@@ -2,10 +2,10 @@ use crate::{actions, context::SproutContext};
use alloc::rc::Rc;
use alloc::vec::Vec;
use alloc::{format, vec};
use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use edera_sprout_config::actions::chainload::ChainloadConfiguration;
use edera_sprout_config::actions::edera::EderaConfiguration;
use edera_sprout_parsing::{build_xen_config, combine_options, empty_is_none};
use edera_sprout_parsing::{append_initrd, build_xen_config, combine_options, empty_is_none};
use eficore::media_loader::{
MediaLoaderHandle,
constants::xen::{
@@ -40,6 +40,11 @@ fn read_loader_payload(context: &Rc<SproutContext>, what: &str, path: &str) -> R
/// Executes the edera action which will boot the Edera hypervisor with the specified
/// `configuration` and `context`. This action uses Edera-specific Xen EFI stub functionality.
pub fn edera(context: Rc<SproutContext>, configuration: &EderaConfiguration) -> Result<()> {
// The initrd can be provided as either a single initrd or a chain, but not both.
if configuration.initrd.is_some() && !configuration.initrd_chain.is_empty() {
bail!("initrd and initrd-chain cannot be used together");
}
// Only register the initrd media loader if the user actually configured one.
let xen_opts = combine_options(context.stamp_iter(configuration.xen_options.iter()));
let dom0_args = combine_options(context.stamp_iter(configuration.kernel_options.iter()));
@@ -80,12 +85,27 @@ pub fn edera(context: Rc<SproutContext>, configuration: &EderaConfiguration) ->
register_media_loader_bytes(XEN_EFI_KERNEL_MEDIA_GUID, "kernel", kernel_bytes)
.context("unable to register kernel media loader")?;
// Extend PCR 9 with the initrd bytes (empty when no initrd is
// configured).
let initrd_bytes = match empty_is_none(configuration.initrd.as_ref()) {
Some(p) => read_loader_payload(&context, "initrd", p)?,
None => Vec::new(),
};
// Read the initrd, or each initrd in the chain concatenated in order, then
// extend PCR 9 with the combined bytes (empty when no initrd is configured).
// Paths are stamped first, and paths that are empty after stamping or that refer
// to the root of a filesystem are skipped, the same as the chainload action.
let mut initrd_bytes = Vec::new();
let initrd_paths = configuration
.initrd
.iter()
.chain(configuration.initrd_chain.iter())
.map(|item| context.stamp(item));
for path in initrd_paths {
let Some(path) = empty_is_none(Some(path)) else {
continue;
};
let Some(content) = actions::chainload::read_initrd(&context, &path)
.context("unable to read initrd file")?
else {
continue;
};
append_initrd(&mut initrd_bytes, &content);
}
PlatformTpm::log_event(
PlatformTpm::PCR_INITRD,
&initrd_bytes,
@@ -111,6 +131,7 @@ pub fn edera(context: Rc<SproutContext>, configuration: &EderaConfiguration) ->
path: configuration.xen.clone(),
options: vec![],
linux_initrd: None,
linux_initrd_chain: vec![],
},
)
.context("unable to chainload to xen");
+24 -7
View File
@@ -48,19 +48,36 @@ pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> {
// Trade the filesystem protocol for the uefi filesystem helper.
let mut filesystem = FileSystem::new(filesystem);
// A scan that fails is skipped, as a filesystem that can't be read should not
// prevent autoconfiguring the other filesystems.
// Scan the filesystem for BLS supported configurations.
let bls_found = bls::scan(&mut filesystem, &root, config)
.context("unable to scan for bls configurations")?;
let bls_found = match bls::scan(&mut filesystem, &root, config) {
Ok(found) => found,
Err(error) => {
warn!(
"unable to scan filesystem for bls configurations: {:#}",
error
);
false
}
};
// If BLS was not found, scan for Linux configurations.
if !bls_found {
linux::scan(&mut filesystem, &root, config)
.context("unable to scan for linux configurations")?;
if !bls_found && let Err(error) = linux::scan(&mut filesystem, &root, config) {
warn!(
"unable to scan filesystem for linux configurations: {:#}",
error
);
}
// Always look for Windows configurations.
windows::scan(&mut filesystem, &root, config)
.context("unable to scan for windows configurations")?;
if let Err(error) = windows::scan(&mut filesystem, &root, config) {
warn!(
"unable to scan filesystem for windows configurations: {:#}",
error
);
}
}
Ok(())
+21 -5
View File
@@ -1,3 +1,4 @@
use crate::generators::bls::BLS_INITRD_SLOTS;
use alloc::string::ToString;
use alloc::{format, vec};
use anyhow::{Context, Result};
@@ -45,12 +46,22 @@ pub fn scan(
.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.
// We actually iterate the entries to see if there are any entry files, as the
// directory itself always has the "." and ".." items, even when it is empty.
let has_entries_dir = filesystem
.read_dir(bls_entries_path)
.ok()
.and_then(|mut iterator| iterator.next())
.map(|entry| entry.is_ok())
.map(|mut iterator| {
iterator.any(|entry| {
entry.is_ok_and(|entry| {
entry.is_regular_file()
&& entry
.file_name()
.to_string()
.to_lowercase()
.ends_with(".conf")
})
})
})
.unwrap_or(false);
// Detect if a BLS supported configuration is on this filesystem.
@@ -83,10 +94,15 @@ pub fn scan(
// Generate a chainload configuration for BLS.
// BLS will provide these values to us.
// Every initrd slot is chained in order. Unused slots stamp to the root of the
// filesystem, which the chainload action skips.
let chainload = ChainloadConfiguration {
path: format!("{}\\$chainload", root),
options: vec!["$options".to_string()],
linux_initrd: Some(format!("{}\\$initrd", root)),
linux_initrd: None,
linux_initrd_chain: (0..BLS_INITRD_SLOTS)
.map(|slot| format!("{}\\$initrd-{}", root, slot))
.collect(),
};
// Insert the chainload action into the configuration.
+13 -8
View File
@@ -13,6 +13,7 @@ use edera_sprout_parsing::{
LINUX_INITRAMFS_PREFIXES, LINUX_KERNEL_PREFIXES, initramfs_candidates, match_kernel_prefix,
unique_hash,
};
use log::warn;
use uefi::CString16;
use uefi::fs::{FileSystem, Path, PathBuf};
use uefi::proto::device_path::DevicePath;
@@ -22,8 +23,8 @@ use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
const LINUX_CHAINLOAD_ACTION_PREFIX: &str = "linux-chainload-";
/// The locations to scan for kernel pairs.
/// We will check for symlinks and if this directory is a symlink, we will skip it.
/// The empty string represents the root of the filesystem.
/// A location that doesn't exist or can't be read is skipped.
/// The "\\" location represents the root of the filesystem.
const SCAN_LOCATIONS: &[&str] = &["\\boot", "\\"];
/// This is really silly, but if what we are booting is the Canonical stubble stub,
@@ -125,10 +126,11 @@ fn scan_directory(filesystem: &mut FileSystem, path: &str) -> Result<Vec<KernelP
let mut initramfs_path = path_for_join.clone();
initramfs_path.push(Path::new(&initramfs));
// Check if the initramfs path exists, if it does, break out of the loop.
// Check if the initramfs path is a file, if it is, break out of the loop.
// A path that can't be checked is treated as not existing.
if filesystem
.try_exists(&initramfs_path)
.context("unable to check if initramfs path exists")?
.metadata(&initramfs_path)
.is_ok_and(|metadata| metadata.is_regular_file())
{
break Some(initramfs_path);
}
@@ -168,10 +170,12 @@ pub fn scan(
let root_unique_hash = unique_hash(&root);
// Scan all locations for kernel pairs, adding them to the list.
// A location that can't be scanned is skipped, so the other locations are still used.
for location in SCAN_LOCATIONS {
let scanned = scan_directory(filesystem, location)
.with_context(|| format!("unable to scan directory {}", location))?;
pairs.extend(scanned);
match scan_directory(filesystem, location) {
Ok(scanned) => pairs.extend(scanned),
Err(error) => warn!("unable to scan directory {}: {:#}", location, error),
}
}
// If no kernel pairs were found, return false.
@@ -232,6 +236,7 @@ pub fn scan(
path: "$kernel".to_string(),
options: vec!["$linux-options".to_string()],
linux_initrd: Some("$initrd".to_string()),
linux_initrd_chain: vec![],
};
// Insert the chainload action into the configuration.
+67 -4
View File
@@ -1,10 +1,14 @@
use crate::options::SproutOptions;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use anyhow::{Context, Result, bail};
use core::fmt::Display;
use core::ops::Deref;
use edera_sprout_config::{RootConfiguration, latest_version};
use eficore::platform::tpm::PlatformTpm;
use log::info;
use log::{info, warn};
use serde_ignored::Path;
use toml::Value;
use uefi::proto::device_path::LoadedImageDevicePath;
@@ -35,6 +39,59 @@ fn load_raw_config(options: &SproutOptions) -> Result<Vec<u8>> {
Ok(content)
}
/// Format the configuration key `path` with dots, like "actions.linux.chainload.path".
fn key_path(path: &Path) -> String {
// Joins the `parent` path and the `key` with a dot, unless the parent is the root.
fn join(parent: &Path, key: impl Display) -> String {
match parent {
Path::Root => key.to_string(),
parent => format!("{}.{}", key_path(parent), key),
}
}
match path {
Path::Root => String::new(),
Path::Seq { parent, index } => join(parent, index),
Path::Map { parent, key } => join(parent, key),
// Optional values and wrapper types don't add a key of their own.
Path::Some { parent }
| Path::NewtypeStruct { parent }
| Path::NewtypeVariant { parent } => key_path(parent),
}
}
/// Warn about each declaration in `config` that configures more than one kind.
/// For example, an action with both chainload and print configured only runs chainload.
fn warn_multiple_kinds(config: &RootConfiguration) {
for (name, action) in &config.actions {
let kinds = [
action.chainload.is_some(),
action.print.is_some(),
action.edera.is_some(),
];
if kinds.iter().filter(|kind| **kind).count() > 1 {
warn!(
"action {} configures more than one action, only one is used",
name
);
}
}
for (name, generator) in &config.generators {
let kinds = [
generator.matrix.is_some(),
generator.bls.is_some(),
generator.list.is_some(),
];
if kinds.iter().filter(|kind| **kind).count() > 1 {
warn!(
"generator {} configures more than one generator, only one is used",
name
);
}
}
}
/// Loads the [RootConfiguration] for Sprout.
pub fn load(options: &SproutOptions) -> Result<RootConfiguration> {
// Load the raw configuration from the sprout config file.
@@ -59,9 +116,15 @@ pub fn load(options: &SproutOptions) -> Result<RootConfiguration> {
}
// If the version is supported, parse the full configuration.
let config: RootConfiguration = value
.try_into()
.context("unable to parse sprout.toml file")?;
// Keys that aren't part of the configuration, such as misspelled keys, are ignored.
// Warn about each one, as the configuration may not do what was intended.
let config: RootConfiguration = serde_ignored::deserialize(value, |path| {
warn!("ignoring unknown configuration key: {}", key_path(&path));
})
.context("unable to parse sprout.toml file")?;
// Warn about declarations that configure more than one kind, as only one is used.
warn_multiple_kinds(&config);
// Return the parsed configuration.
Ok(config)
+9
View File
@@ -14,6 +14,11 @@ 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;
/// The maximum length of a value produced by [SproutContext::finalize].
/// A value that refers to itself more than once doubles in length on every iteration,
/// so this stops it from using up all memory before the iteration limit is reached.
const CONTEXT_FINALIZE_VALUE_LENGTH_LIMIT: usize = 64 * 1024;
/// Declares a root context for Sprout.
/// This contains data that needs to be shared across Sprout.
pub struct RootContext {
@@ -201,6 +206,10 @@ impl SproutContext {
// If the value changed, we need to re-stamp it.
did_change = true;
}
// Ensure the value has not grown without bound.
if result.len() > CONTEXT_FINALIZE_VALUE_LENGTH_LIMIT {
bail!("value {} is too long while finalizing context", key);
}
// Insert the new value into the value map.
values.insert(key.clone(), result);
}
+10
View File
@@ -74,6 +74,16 @@ impl BootableEntry {
self.title = self.context.stamp(&self.title);
}
/// Restamp the declared sort key with the current context.
/// A sort key that was set on this entry directly is left as is.
pub fn restamp_sort_key(&mut self) {
if self.sort_key.is_none()
&& let Some(ref sort_key) = self.declaration.sort_key
{
self.sort_key = Some(self.context.stamp(sort_key));
}
}
/// Mark this entry as the default entry.
pub fn mark_default(&mut self) {
self.default = true;
@@ -6,11 +6,106 @@ use core::ops::Deref;
use core::str::FromStr;
use edera_sprout_config::extractors::filesystem_device_match::FilesystemDeviceMatchExtractor;
use eficore::partition::PartitionGuidForm;
use log::warn;
use uefi::fs::{FileSystem, Path};
use uefi::proto::device_path::DevicePath;
use uefi::proto::media::file::{File, FileSystemVolumeLabel};
use uefi::proto::media::fs::SimpleFileSystem;
use uefi::{CString16, Guid};
use uefi::{CString16, Guid, Handle};
/// Check whether the filesystem at `handle` matches all the criteria of the `extractor`
/// configuration inside the `context`. The partition UUIDs are parsed ahead of time into
/// `partition_uuid` and `partition_type_uuid`.
fn is_match(
context: &Rc<SproutContext>,
extractor: &FilesystemDeviceMatchExtractor,
handle: Handle,
partition_uuid: Option<Guid>,
partition_type_uuid: Option<Guid>,
) -> Result<bool> {
// Check if the partition info matches partition uuid criteria.
if let Some(parsed_uuid) = partition_uuid {
// Fetch the root of the device.
let root = uefi::boot::open_protocol_exclusive::<DevicePath>(handle)
.context("unable to fetch the device path of the filesystem")?
.deref()
.to_boxed();
// Fetch the partition uuid for this filesystem.
let partition_uuid =
eficore::partition::partition_guid(&root, PartitionGuidForm::Partition)
.context("unable to fetch the partition uuid of the filesystem")?;
// Compare the partition uuid to the parsed uuid.
if partition_uuid != Some(parsed_uuid) {
return Ok(false);
}
}
// Check if the partition info matches partition type uuid criteria.
if let Some(parsed_uuid) = partition_type_uuid {
// Fetch the root of the device.
let root = uefi::boot::open_protocol_exclusive::<DevicePath>(handle)
.context("unable to fetch the device path of the filesystem")?
.deref()
.to_boxed();
// Fetch the partition type uuid for this filesystem.
let partition_type_uuid =
eficore::partition::partition_guid(&root, PartitionGuidForm::PartitionType)
.context("unable to fetch the partition uuid of the filesystem")?;
// Compare the partition type uuid to the parsed uuid.
if partition_type_uuid != Some(parsed_uuid) {
return Ok(false);
}
}
// The remaining criteria need the filesystem itself.
if extractor.has_label.is_none() && extractor.has_item.is_none() {
return Ok(true);
}
// Open the filesystem protocol for this handle.
let mut filesystem = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(handle)
.context("unable to open filesystem protocol")?;
// Check if the filesystem matches label criteria.
if let Some(ref label) = extractor.has_label {
let want_label = CString16::try_from(context.stamp(label).as_str())
.context("unable to convert label to CString16")?;
let mut root = filesystem
.open_volume()
.context("unable to open filesystem volume")?;
let label = root
.get_boxed_info::<FileSystemVolumeLabel>()
.context("unable to get filesystem volume label")?;
if label.volume_label() != want_label {
return Ok(false);
}
}
// Check if the filesystem matches item criteria.
if let Some(ref item) = extractor.has_item {
let want_item = CString16::try_from(context.stamp(item).as_str())
.context("unable to convert item to CString16")?;
let mut filesystem = FileSystem::new(filesystem);
// Check the metadata of the item.
// Ignore filesystem errors as we can't do anything useful with the error.
let Some(metadata) = filesystem.metadata(Path::new(&want_item)).ok() else {
return Ok(false);
};
// Only check directories and files.
if !(metadata.is_directory() || metadata.is_regular_file()) {
return Ok(false);
}
}
Ok(true)
}
/// Extract a filesystem device path using the specified `context` and `extractor` configuration.
pub fn extract(
@@ -26,107 +121,43 @@ pub fn extract(
bail!("at least one criteria is required for filesystem-device-match");
}
// Parse the partition uuid from the extractor.
let partition_uuid = extractor
.has_partition_uuid
.as_deref()
.map(Guid::from_str)
.transpose()
.map_err(|e| anyhow!("unable to parse has-partition-uuid: {}", e))?;
// Parse the partition type uuid from the extractor.
let partition_type_uuid = extractor
.has_partition_type_uuid
.as_deref()
.map(Guid::from_str)
.transpose()
.map_err(|e| anyhow!("unable to parse has-partition-type-uuid: {}", e))?;
// Find all the filesystems inside the UEFI stack.
let handles = uefi::boot::find_handles::<SimpleFileSystem>()
.context("unable to find filesystem handles")?;
// Iterate over all the filesystems and check if they match the criteria.
for handle in handles {
// This defines whether a match has been found.
let mut has_match = false;
// Check if the partition info matches partition uuid criteria.
if let Some(ref has_partition_uuid) = extractor.has_partition_uuid {
// Parse the partition uuid from the extractor.
let parsed_uuid = Guid::from_str(has_partition_uuid)
.map_err(|e| anyhow!("unable to parse has-partition-uuid: {}", e))?;
// Fetch the root of the device.
let root = uefi::boot::open_protocol_exclusive::<DevicePath>(handle)
.context("unable to fetch the device path of the filesystem")?
.deref()
.to_boxed();
// Fetch the partition uuid for this filesystem.
let partition_uuid =
eficore::partition::partition_guid(&root, PartitionGuidForm::Partition)
.context("unable to fetch the partition uuid of the filesystem")?;
// Compare the partition uuid to the parsed uuid.
// If it does not match, continue to the next filesystem.
if partition_uuid != Some(parsed_uuid) {
// A filesystem that can't be checked is skipped, as it should not prevent
// finding a match on the other filesystems or using the fallback value.
match is_match(
&context,
extractor,
handle,
partition_uuid,
partition_type_uuid,
) {
Ok(true) => {}
Ok(false) => continue,
Err(error) => {
warn!("skipping filesystem, unable to check it: {:#}", error);
continue;
}
has_match = true;
}
// Check if the partition info matches partition type uuid criteria.
if let Some(ref has_partition_type_uuid) = extractor.has_partition_type_uuid {
// Parse the partition type uuid from the extractor.
let parsed_uuid = Guid::from_str(has_partition_type_uuid)
.map_err(|e| anyhow!("unable to parse has-partition-type-uuid: {}", e))?;
// Fetch the root of the device.
let root = uefi::boot::open_protocol_exclusive::<DevicePath>(handle)
.context("unable to fetch the device path of the filesystem")?
.deref()
.to_boxed();
// Fetch the partition type uuid for this filesystem.
let partition_type_uuid =
eficore::partition::partition_guid(&root, PartitionGuidForm::PartitionType)
.context("unable to fetch the partition uuid of the filesystem")?;
// Compare the partition type uuid to the parsed uuid.
// If it does not match, continue to the next filesystem.
if partition_type_uuid != Some(parsed_uuid) {
continue;
}
has_match = true;
}
// Open the filesystem protocol for this handle.
let mut filesystem = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(handle)
.context("unable to open filesystem protocol")?;
// Check if the filesystem matches label criteria.
if let Some(ref label) = extractor.has_label {
let want_label = CString16::try_from(context.stamp(label).as_str())
.context("unable to convert label to CString16")?;
let mut root = filesystem
.open_volume()
.context("unable to open filesystem volume")?;
let label = root
.get_boxed_info::<FileSystemVolumeLabel>()
.context("unable to get filesystem volume label")?;
if label.volume_label() != want_label {
continue;
}
has_match = true;
}
// Check if the filesystem matches item criteria.
if let Some(ref item) = extractor.has_item {
let want_item = CString16::try_from(context.stamp(item).as_str())
.context("unable to convert item to CString16")?;
let mut filesystem = FileSystem::new(filesystem);
// Check the metadata of the item.
// Ignore filesystem errors as we can't do anything useful with the error.
let Some(metadata) = filesystem.metadata(Path::new(&want_item)).ok() else {
continue;
};
// Only check directories and files.
if !(metadata.is_directory() || metadata.is_regular_file()) {
continue;
}
has_match = true;
}
// If there is no match, continue to the next filesystem.
if !has_match {
continue;
}
// If we have a match, return the device root path.
+35 -7
View File
@@ -18,11 +18,18 @@ use uefi::{
proto::media::fs::SimpleFileSystem,
};
/// The number of initrd slots set on each BLS entry.
/// Entries set `initrd-0` through `initrd-7`, and slots without an initrd are empty.
pub const BLS_INITRD_SLOTS: usize = 8;
// TODO(azenla): remove this once variable substitution is implemented.
/// This function is used to remove the `tuned_initrd` variable from entry values.
/// This function is used to remove the `tuned_initrd` variable from the initrd paths.
/// Fedora uses tuned which adds an initrd that shouldn't be used.
fn quirk_initrd_remove_tuned(input: String) -> String {
input.replace("$tuned_initrd", "").trim().to_string()
fn quirk_initrd_remove_tuned(paths: Vec<String>) -> Vec<String> {
paths
.into_iter()
.filter(|path| path != "$tuned_initrd")
.collect()
}
/// Sorts two entries according to the BLS sort system.
@@ -139,6 +146,18 @@ pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Ve
continue;
}
// Put the initrds through a quirk modifier to support Fedora.
let initrds = quirk_initrd_remove_tuned(entry.initrd_paths());
// Skip entries with more initrds than there are slots, as they can't be fully loaded.
if initrds.len() > BLS_INITRD_SLOTS {
warn!(
"bls entry {} has more than {} initrds, skipping",
name, BLS_INITRD_SLOTS
);
continue;
}
// Produce a new sprout context for the entry with the extracted values.
let mut context = context.fork();
@@ -148,9 +167,6 @@ pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Ve
let version = entry.version().unwrap_or_default();
let machine_id = entry.machine_id().unwrap_or_default();
// Put the initrd through a quirk modifier to support Fedora.
let initrd = quirk_initrd_remove_tuned(entry.initrd_path().unwrap_or_default());
// Combine the title with the version if a version is present, except if it already contains it.
// Sometimes BLS will have a version in the title already, and this makes it unique.
let title_full = if !version.is_empty() && !title_base.contains(&version) {
@@ -163,7 +179,19 @@ pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Ve
context.set("title", title_full);
context.set("chainload", chainload);
context.set("options", options);
context.set("initrd", initrd);
// The initrd value keeps the last initrd, which is what it held before
// multiple initrds were supported.
context.set("initrd", initrds.last().cloned().unwrap_or_default());
// Set every initrd slot, even the unused ones. An unset slot would let "$initrd-1"
// match "$initrd" instead, producing the initrd value followed by "-1".
for slot in 0..BLS_INITRD_SLOTS {
context.set(
format!("initrd-{}", slot),
initrds.get(slot).cloned().unwrap_or_default(),
);
}
context.set("version", version);
context.set("machine-id", machine_id);
+151 -49
View File
@@ -64,6 +64,17 @@ pub mod sbat;
/// The delay to wait for when an error occurs in Sprout.
const DELAY_ON_ERROR: Duration = Duration::from_secs(10);
/// Unwraps the `result` of a bootloader interface operation, logging the error if one occurs.
/// The bootloader interface only exchanges information with the operating system, so a
/// firmware variable that can't be read or written should not prevent booting.
/// If an error occurs, the default value is provided instead.
fn advisory<T: Default>(result: Result<T>) -> T {
result.unwrap_or_else(|error| {
warn!("{:#}", error);
T::default()
})
}
/// Run Sprout, returning an error if one occurs.
fn run() -> Result<()> {
// For safety reasons, we will note that Secure Boot is in beta on Sprout.
@@ -75,23 +86,36 @@ fn run() -> Result<()> {
let timer = PlatformTimer::start();
// Mark the initialization of Sprout in the bootloader interface.
BootloaderInterface::mark_init(&timer)
.context("unable to mark initialization in bootloader interface")?;
advisory(
BootloaderInterface::mark_init(&timer)
.context("unable to mark initialization in bootloader interface"),
);
// Tell the bootloader interface what firmware we are running on.
BootloaderInterface::set_firmware_info()
.context("unable to set firmware info in bootloader interface")?;
advisory(
BootloaderInterface::set_firmware_info()
.context("unable to set firmware info in bootloader interface"),
);
// Tell the bootloader interface what loader is being used.
BootloaderInterface::set_loader_info()
.context("unable to set loader info in bootloader interface")?;
advisory(
BootloaderInterface::set_loader_info()
.context("unable to set loader info in bootloader interface"),
);
// Acquire the number of active PCR banks on the TPM.
// If no TPM is available, this will return zero.
let active_pcr_banks = PlatformTpm::active_pcr_banks()?;
// This is only reported to the bootloader interface, so a TPM that fails to report its
// PCR banks is treated as having none, rather than preventing boot.
let active_pcr_banks = PlatformTpm::active_pcr_banks().unwrap_or_else(|error| {
warn!("unable to determine the active TPM PCR banks: {}", error);
0
});
// Tell the bootloader interface what the number of active PCR banks is.
BootloaderInterface::set_tpm2_active_pcr_banks(active_pcr_banks)
.context("unable to set tpm2 active PCR banks in bootloader interface")?;
advisory(
BootloaderInterface::set_tpm2_active_pcr_banks(active_pcr_banks)
.context("unable to set tpm2 active PCR banks in bootloader interface"),
);
// Parse the options to the sprout executable.
let options = SproutOptions::parse().context("unable to parse options")?;
@@ -118,20 +142,26 @@ fn run() -> Result<()> {
};
// Grab the partition GUID of the ESP that sprout was loaded from.
let loaded_image_partition_guid =
// This is only reported to the bootloader interface.
let loaded_image_partition_guid = advisory(
eficore::partition::partition_guid(&loaded_image_path, PartitionGuidForm::Partition)
.context("unable to retrieve loaded image partition guid")?;
.context("unable to retrieve loaded image partition guid"),
);
// Set the partition GUID of the ESP that sprout was loaded from in the bootloader interface.
if let Some(loaded_image_partition_guid) = loaded_image_partition_guid {
// Tell the system about the partition GUID.
BootloaderInterface::set_partition_guid(&loaded_image_partition_guid)
.context("unable to set partition guid in bootloader interface")?;
advisory(
BootloaderInterface::set_partition_guid(&loaded_image_partition_guid)
.context("unable to set partition guid in bootloader interface"),
);
}
// Tell the bootloader interface what the loaded image path is.
BootloaderInterface::set_loader_path(&loaded_image_path)
.context("unable to set loader path in bootloader interface")?;
advisory(
BootloaderInterface::set_loader_path(&loaded_image_path)
.context("unable to set loader path in bootloader interface"),
);
// Create the root context.
let mut root = RootContext::new(loaded_image_path, timer, options);
@@ -230,19 +260,29 @@ fn run() -> Result<()> {
}
}
for entry in &mut entries {
// Entries whose context can't be finalized are skipped, as one broken entry
// should not prevent booting any of the other entries.
entries.retain_mut(|entry| {
let mut context = entry.context().fork();
// 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()
.context("unable to finalize context")?
.freeze();
let context = match context.finalize() {
Ok(context) => context.freeze(),
Err(error) => {
warn!(
"skipping entry {}, unable to finalize context: {:#}",
entry.name(),
error
);
return false;
}
};
// Provide the new context to the bootable entry.
entry.swap_context(context);
// Restamp the title with any values.
// Restamp the title and sort key with any values.
entry.restamp_title();
entry.restamp_sort_key();
// Mark this entry as the default entry if it is declared as such.
if let Some(ref default_entry) = config.options.default_entry {
@@ -251,27 +291,36 @@ fn run() -> Result<()> {
entry.mark_default();
}
}
}
true
});
// Sort the entries by their sort key, finalizing the order to show entries. This happens
// in reverse order so that entries that would come last show up first in the menu.
entries.sort_by(|a, b| compare_versions(a.sort_key(), b.sort_key()).reverse());
// Tell the bootloader interface what entries are available.
BootloaderInterface::set_entries(entries.iter().map(|entry| entry.name()))
.context("unable to set entries in bootloader interface")?;
advisory(
BootloaderInterface::set_entries(entries.iter().map(|entry| entry.name()))
.context("unable to set entries in bootloader interface"),
);
// Acquire the timeout setting from the bootloader interface.
let bootloader_interface_timeout =
BootloaderInterface::get_timeout().context("unable to get bootloader interface timeout")?;
let bootloader_interface_timeout = advisory(
BootloaderInterface::get_timeout().context("unable to get bootloader interface timeout"),
);
// Acquire the default entry from the bootloader interface.
let bootloader_interface_default_entry = BootloaderInterface::get_default_entry()
.context("unable to get bootloader interface default entry")?;
let bootloader_interface_default_entry = advisory(
BootloaderInterface::get_default_entry()
.context("unable to get bootloader interface default entry"),
);
// Acquire the oneshot entry from the bootloader interface.
let bootloader_interface_oneshot_entry = BootloaderInterface::get_oneshot_entry()
.context("unable to get bootloader interface oneshot entry")?;
let bootloader_interface_oneshot_entry = advisory(
BootloaderInterface::get_oneshot_entry()
.context("unable to get bootloader interface oneshot entry"),
);
// If --boot is specified, boot that entry immediately.
let mut force_boot_entry = context.root().options().boot.clone();
@@ -286,11 +335,21 @@ fn run() -> Result<()> {
.menu_timeout
.unwrap_or(config.options.menu_timeout);
// Whether the boot menu should wait for the user, instead of counting down.
let mut wait_for_user = false;
// Apply bootloader interface timeout settings.
match bootloader_interface_timeout {
BootloaderInterfaceTimeout::MenuForce => {
// Force the boot menu.
// Force the boot menu, and wait for the user to choose an entry.
force_boot_menu = true;
wait_for_user = true;
}
BootloaderInterfaceTimeout::MenuForceTimeout(timeout) => {
// Force the boot menu with the specified timeout.
force_boot_menu = true;
menu_timeout = timeout;
}
BootloaderInterfaceTimeout::MenuHidden | BootloaderInterfaceTimeout::MenuDisabled => {
@@ -310,15 +369,27 @@ fn run() -> Result<()> {
// Apply bootloader interface default entry settings.
if let Some(ref bootloader_interface_default_entry) = bootloader_interface_default_entry {
// Iterate over all the entries and mark the default entry as the one specified.
for entry in &mut entries {
// Mark the entry as the default entry if it matches the specified entry.
// If the entry does not match the specified entry, unmark it as the default entry.
if entry.is_match(bootloader_interface_default_entry) {
entry.mark_default();
} else {
entry.unmark_default();
if entries
.iter()
.any(|entry| entry.is_match(bootloader_interface_default_entry))
{
// Iterate over all the entries and mark the default entry as the one specified.
for entry in &mut entries {
// Mark the entry as the default entry if it matches the specified entry.
// If the entry does not match the specified entry, unmark it as the default entry.
if entry.is_match(bootloader_interface_default_entry) {
entry.mark_default();
} else {
entry.unmark_default();
}
}
} else {
// If no entry matches, such as when the entry was removed, the setting is ignored
// so that the configured default entry is still used.
warn!(
"ignoring bootloader interface default entry '{}': no matching entry",
bootloader_interface_default_entry
);
}
}
@@ -336,21 +407,52 @@ fn run() -> Result<()> {
}
// Convert the menu timeout to a duration.
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}"))?
// A zero timeout boots without showing the menu, so a forced menu with a zero timeout
// waits for the user instead.
let menu_timeout = if wait_for_user || (force_boot_menu && menu_timeout == 0) {
None
} else {
Some(Duration::from_secs(menu_timeout))
};
// Determine the menu style based on the options or configuration.
// We prefer the options over the configuration to allow for overriding.
let menu_style = context
.root()
.options()
.menu_style
.unwrap_or(config.options.menu_style);
// Find the forced boot entry, unless the boot menu is forced.
// If the forced boot entry can't be found, such as when it was removed,
// the boot menu is used instead.
let forced_entry = force_boot_entry
.as_ref()
.filter(|_| !force_boot_menu)
.and_then(|force_boot_entry| {
let entry = BootableEntry::find(force_boot_entry, entries.iter());
if entry.is_none() {
warn!(
"unable to find entry '{}', using the boot menu",
force_boot_entry
);
}
entry
});
// Use the forced boot entry if possible, otherwise pick an entry using a boot menu.
let entry = match forced_entry {
Some(entry) => entry,
// Delegate to the menu to select an entry to boot.
menu::select(&timer, menu_timeout, &entries)
.context("unable to select entry via boot menu")?
None => menu::select(&timer, menu_timeout, menu_style, &entries)
.context("unable to select entry via boot menu")?,
};
// Tell the bootloader interface what the selected entry is.
BootloaderInterface::set_selected_entry(entry.name().to_string())
.context("unable to set selected entry in bootloader interface")?;
advisory(
BootloaderInterface::set_selected_entry(entry.name().to_string())
.context("unable to set selected entry in bootloader interface"),
);
// Execute the late phase, now that the entry is chosen but before its actions are executed.
phase(context.clone(), &config.phases.late).context("unable to execute late phase")?;
+86 -137
View File
@@ -1,36 +1,44 @@
use crate::entries::BootableEntry;
use crate::menu::basic::BasicMenu;
use crate::menu::simple::SimpleMenu;
use alloc::vec;
use anyhow::{Context, Result};
use core::time::Duration;
use edera_sprout_config::MenuStyle;
use eficore::bootloader_interface::BootloaderInterface;
use eficore::platform::timer::PlatformTimer;
use log::{info, warn};
use log::warn;
use uefi::ResultExt;
use uefi::boot::TimerTrigger;
use uefi::proto::console::text::{Input, Key, ScanCode};
use uefi::proto::console::text::{Input, Key};
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'];
/// basic: A menu that prints the entries and selects them by number.
pub mod basic;
/// 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,
/// simple: A full-screen menu that selects entries with the arrow keys.
pub mod simple;
/// The longest duration a single timer is set for. This is well within the range of
/// the 100ns units that timers are set in, and still over a century long.
const MAX_TIMER_DURATION: Duration = Duration::from_secs(u32::MAX as u64);
/// A boot menu that can be shown to select an entry to boot.
pub trait BootMenu {
/// Select an entry from `entries` to boot. If no entry is chosen before `timeout` passes,
/// the default entry is selected. If `timeout` is zero, the default entry is selected
/// without showing the menu. If `timeout` is [None], the menu waits for the user.
fn select<'a>(
&self,
timeout: Option<Duration>,
entries: &'a [BootableEntry],
) -> Result<&'a BootableEntry>;
}
/// 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> {
/// Read a key from `input`, giving up once `timeout` passes.
/// If `timeout` is [None], this waits for a key indefinitely.
/// Returns the key that was pressed, or [None] if the timeout passed.
pub fn read_key(input: &mut Input, timeout: Option<Duration>) -> Result<Option<Key>> {
// The event to wait for a key press.
let key_event = input
.wait_for_key_event()
@@ -44,17 +52,38 @@ fn read(input: &mut Input, timeout: &Duration) -> Result<MenuOperation> {
.context("unable to create timer event")?
};
// Set a timer to trigger after the specified duration.
let trigger = TimerTrigger::Relative(*timeout);
uefi::boot::set_timer(&timer_event, trigger).context("unable to set timeout timer")?;
let events = vec![timer_event, key_event];
// Wait for either the timer event or the key event to trigger.
// Wait for a key until the timer triggers.
// Store the result so that we can free the timer event.
let event_result = uefi::boot::wait_for_event(&events)
.discard_errdata()
.context("unable to wait for event");
let result = (|| {
// Set a timer to trigger after the specified duration.
// The timer is limited to what the firmware can represent, as a longer timeout
// can't be converted into a timer trigger.
// Without a timeout, the timer is never set, so it never triggers.
if let Some(timeout) = timeout {
let trigger = TimerTrigger::Relative(timeout.min(MAX_TIMER_DURATION));
uefi::boot::set_timer(&events[0], trigger).context("unable to set timeout timer")?;
}
loop {
// Wait for either the timer event or the key event to trigger.
let event = uefi::boot::wait_for_event(&events)
.discard_errdata()
.context("unable to wait for event")?;
// The first event is the timer event, so the timeout has passed.
if event == 0 {
return Ok(None);
}
// Some firmware signals the key event without a key being available.
// In that case, keep waiting on the same timer so the timeout is not extended.
if let Some(key) = input.read_key().context("unable to read key")? {
return Ok(Some(key));
}
}
})();
// Close the timer event that we acquired.
// We don't need to close the key event because it is owned globally.
@@ -63,131 +92,51 @@ fn read(input: &mut Input, timeout: &Duration) -> Result<MenuOperation> {
// Store the result of the close event so we can determine if we can safely assert it.
let close_event_result =
uefi::boot::close_event(timer_event).context("unable to close timer event");
if event_result.is_err()
if result.is_err()
&& let Err(ref close_event_error) = close_event_result
{
// Log a warning if we failed to close the timer event.
// This is done to ensure we don't mask the wait_for_event error.
// This is done to ensure we don't mask the error from reading the key.
warn!("unable to close timer event: {}", close_event_error);
} else {
// If we reach here, we can safely assert that the close event succeeded without
// masking the wait_for_event error.
// masking the error from reading the key.
close_event_result?;
}
}
// Acquire the event that triggered.
let event = event_result?;
// 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.
// Some firmware signals the key event without a key being available,
// in which case there is nothing to do.
let Some(key) = input.read_key().context("unable to read key")? else {
return Ok(MenuOperation::Nop);
};
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),
}
result
}
/// 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);
}
}
// 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 {
info!("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.
/// Shows a boot menu of the specified `style` to select a bootable entry to boot.
/// See [BootMenu::select] for how `timeout` is handled.
pub fn select<'live>(
timer: &'live PlatformTimer,
timeout: Duration,
timeout: Option<Duration>,
style: MenuStyle,
entries: &'live [BootableEntry],
) -> Result<&'live BootableEntry> {
// Notify the bootloader interface that we are about to display the menu.
BootloaderInterface::mark_menu(timer)
.context("unable to mark menu display in bootloader interface")?;
// This is only informational, so it should not prevent booting.
if let Err(error) = BootloaderInterface::mark_menu(timer) {
warn!(
"unable to mark menu display in bootloader interface: {:#}",
error
);
}
// Acquire the standard input device and run the boot menu.
uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries))
// Pick the menu that implements the requested style.
match style {
MenuStyle::Basic => BasicMenu.select(timeout, entries),
// The simple menu needs a console that can move the cursor and set colors, which
// not every console supports. If it fails, the basic menu is used instead.
MenuStyle::Simple => SimpleMenu.select(timeout, entries).or_else(|error| {
warn!(
"unable to show the simple boot menu, using the basic menu: {:#}",
error
);
BasicMenu.select(timeout, entries)
}),
}
}
+184
View File
@@ -0,0 +1,184 @@
use crate::entries::BootableEntry;
use crate::menu::{BootMenu, read_key};
use anyhow::{Context, Result};
use core::time::Duration;
use log::info;
use uefi::proto::console::text::{Input, Key, ScanCode};
/// 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'];
/// A boot menu that prints the entries and selects them by number.
pub struct BasicMenu;
/// Represents the operation that can be performed by the boot menu.
#[derive(PartialEq, Eq)]
enum MenuOperation {
/// The user typed a digit of an entry number.
Digit(usize),
/// The user selected the escape key to exit the boot menu.
Exit,
/// The user selected the enter key to boot the typed entry number,
/// or to display the entries again if no number was typed.
Enter,
/// The user selected some other key to display the entries again.
Continue,
/// Timeout occurred.
Timeout,
/// No operation should be performed.
Nop,
}
/// Read a key from the input device with an optional timeout, returning the [MenuOperation]
/// that was performed. Without a timeout, this waits for a key indefinitely.
fn read(input: &mut Input, timeout: Option<Duration>) -> Result<MenuOperation> {
// If the timer triggered, the user did not select a numbered entry.
let Some(key) = read_key(input, timeout)? else {
return Ok(MenuOperation::Timeout);
};
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();
// Serial consoles may send a line feed instead of a carriage return.
if matches!(c, '\r' | '\n') {
return Ok(MenuOperation::Enter);
}
// Find the key pressed in the entry number table or continue.
Ok(ENTRY_NUMBER_TABLE
.iter()
.position(|&x| x == c)
.map(MenuOperation::Digit)
.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),
}
}
/// Find the default entry in `entries`.
fn default_entry(entries: &[BootableEntry]) -> Result<&BootableEntry> {
entries
.iter()
.find(|item| item.is_default())
.context("no default entry available")
}
/// Selects an entry from the list of entries using the boot menu.
fn select_with_input<'a>(
input: &mut Input,
timeout: Option<Duration>,
entries: &'a [BootableEntry],
) -> Result<&'a BootableEntry> {
// If the timeout is zero, boot the default entry without showing the menu.
if timeout.is_some_and(|timeout| timeout.is_zero()) {
return default_entry(entries);
}
// The time to wait for a key, or None once a key stops the countdown.
let mut countdown = timeout;
// The entry number typed so far, while more digits could still select another entry.
let mut typed: Option<usize> = None;
// Whether to print the entries before reading the next key.
let mut display = true;
loop {
if display {
// Print all the entries with the number used to select them.
info!("Boot Menu:");
for (index, entry) in entries.iter().enumerate() {
let title = entry.context().stamp(&entry.declaration().title);
info!(" [{}] {}", index, title);
}
info!("Select a boot entry using the number keys.");
if entries.len() > ENTRY_NUMBER_TABLE.len() {
info!("Press enter after the number if the entry does not boot right away.");
}
info!("Press Escape to exit and enter to display the entries again.");
display = false;
}
let operation = read(input, countdown)?;
// Any key stops the countdown, so the menu waits for the user from now on.
if operation != MenuOperation::Timeout {
countdown = None;
}
match operation {
// A digit of the entry number was typed.
MenuOperation::Digit(digit) => {
let number = typed
.take()
.unwrap_or(0)
.saturating_mul(10)
.saturating_add(digit);
// If another digit could select a different entry, wait for it or for enter.
if number != 0 && number.saturating_mul(10) < entries.len() {
typed = Some(number);
continue;
}
// Otherwise, boot the entry. If the number is invalid, we continue.
let Some(entry) = entries.get(number) else {
info!("invalid entry number");
continue;
};
return Ok(entry);
}
// Enter boots the typed entry number, or displays the entries again.
MenuOperation::Enter => {
let Some(number) = typed.take() else {
display = true;
continue;
};
let Some(entry) = entries.get(number) else {
info!("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 default_entry(entries);
}
// Any other key clears the typed number and displays the entries again.
MenuOperation::Continue => {
typed = None;
display = true;
}
// If the operation is nop, there is nothing to do.
MenuOperation::Nop => {}
}
}
}
impl BootMenu for BasicMenu {
/// Selects an entry using the basic menu.
/// The actual work is done internally in [select_with_input] which is called
/// within the context of the standard input device.
fn select<'a>(
&self,
timeout: Option<Duration>,
entries: &'a [BootableEntry],
) -> Result<&'a BootableEntry> {
uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries))
}
}
+280
View File
@@ -0,0 +1,280 @@
use crate::entries::BootableEntry;
use crate::menu::{BootMenu, read_key};
use alloc::format;
use alloc::string::String;
use anyhow::{Context, Result, anyhow};
use core::fmt::Write;
use core::iter::repeat;
use core::time::Duration;
use uefi::proto::console::text::{Color, Input, Key, Output, ScanCode};
/// The number of columns to assume if the console does not report its mode.
const FALLBACK_COLUMNS: usize = 80;
/// The number of rows to assume if the console does not report its mode.
const FALLBACK_ROWS: usize = 25;
/// How often the countdown is updated.
const TICK: Duration = Duration::from_secs(1);
/// The number of spaces on either side of an entry title.
const ENTRY_PADDING: usize = 2;
/// The hint shown once the countdown has been stopped.
const HINT: &str = "Use the arrow keys to select an entry and press enter to boot.";
/// A full-screen boot menu that selects entries with the arrow keys, similar to systemd-boot.
pub struct SimpleMenu;
/// The position and size of the menu on the console.
struct Layout {
/// The number of rows on the console.
rows: usize,
/// The number of columns that can be drawn to.
columns: usize,
/// The column that entries start at.
x: usize,
/// The row that entries start at.
y: usize,
/// The width of each entry, including padding.
width: usize,
/// The number of entries that fit on the console.
visible: usize,
}
impl Layout {
/// Calculate the layout of `entries` on the console `output`.
fn new(output: &Output, entries: &[BootableEntry]) -> Self {
// Some consoles do not report a mode, so we assume a standard size for them.
let (columns, rows) = output
.current_mode()
.ok()
.flatten()
.map(|mode| (mode.columns(), mode.rows()))
.unwrap_or((FALLBACK_COLUMNS, FALLBACK_ROWS));
// Leave the last column empty, as writing to it makes some consoles scroll.
let columns = columns.saturating_sub(1);
// All entries are the width of the longest title so the highlight lines up.
let longest = entries
.iter()
.map(|entry| entry.title().chars().count())
.max()
.unwrap_or(0);
let width = (longest + ENTRY_PADDING * 2).min(columns);
// Keep the last two rows for the status line and a blank row above it.
let space = rows.saturating_sub(2).max(1);
let visible = entries.len().min(space);
Self {
rows,
columns,
x: (columns - width) / 2,
y: (space - visible) / 2,
width,
visible,
}
}
}
/// Draw `text` at `column` and `row`, padded with spaces or cut to fit `width`.
fn draw(
output: &mut Output,
column: usize,
row: usize,
width: usize,
text: &str,
colors: (Color, Color),
) -> Result<()> {
// Replace anything that isn't printable ASCII, as not all consoles can draw it.
let line: String = text
.chars()
.map(|c| {
if c == ' ' || c.is_ascii_graphic() {
c
} else {
'?'
}
})
.chain(repeat(' '))
.take(width)
.collect();
output
.set_color(colors.0, colors.1)
.context("unable to set console color")?;
output
.set_cursor_position(column, row)
.context("unable to set console cursor position")?;
output
.write_str(&line)
.map_err(|_| anyhow!("unable to write to console"))?;
Ok(())
}
/// Draw the visible `entries` starting at `offset` with `selected` highlighted,
/// and the `status` line at the bottom of the console.
fn render(
output: &mut Output,
layout: &Layout,
entries: &[BootableEntry],
selected: usize,
offset: usize,
status: &str,
) -> Result<()> {
for (row, (index, entry)) in entries
.iter()
.enumerate()
.skip(offset)
.take(layout.visible)
.enumerate()
{
// The selected entry is drawn in inverted colors.
let colors = if index == selected {
(Color::Black, Color::LightGray)
} else {
(Color::LightGray, Color::Black)
};
let text = format!("{:padding$}{}", "", entry.title(), padding = ENTRY_PADDING);
draw(
output,
layout.x,
layout.y + row,
layout.width,
&text,
colors,
)?;
}
// Center the status line across the console.
let padding = layout.columns.saturating_sub(status.len()) / 2;
let text = format!("{:padding$}{}", "", status);
draw(
output,
0,
layout.rows.saturating_sub(1),
layout.columns,
&text,
(Color::LightGray, Color::Black),
)
}
/// Run the menu until an entry is chosen, returning the index of that entry.
/// The `selected` entry is booted if no key is pressed before `timeout` passes.
/// Without a timeout, the menu waits for the user.
fn run(
input: &mut Input,
output: &mut Output,
layout: &Layout,
timeout: Option<Duration>,
entries: &[BootableEntry],
mut selected: usize,
) -> Result<usize> {
let last = entries.len() - 1;
// The first visible entry, which changes when scrolling through a long list.
let mut offset = 0;
// The time left before booting, or None once a key stops the countdown.
let mut remaining = timeout;
loop {
// Scroll so that the selected entry is visible.
if selected < offset {
offset = selected;
} else if selected >= offset + layout.visible {
offset = selected + 1 - layout.visible;
}
let status = match remaining {
Some(remaining) => format!("Boot in {} s.", remaining.as_secs()),
None => String::from(HINT),
};
render(output, layout, entries, selected, offset, &status)?;
// Wait for a key, waking up every tick to update the countdown.
let tick = remaining.map_or(TICK, |remaining| remaining.min(TICK));
let Some(key) = read_key(input, Some(tick))? else {
// Without a countdown there's nothing to do until a key is pressed.
let Some(left) = remaining else {
continue;
};
let left = left.saturating_sub(tick);
if left.is_zero() {
return Ok(selected);
}
remaining = Some(left);
continue;
};
// Any key stops the countdown, which leaves escape with nothing else to do.
remaining = None;
match key {
Key::Special(ScanCode::UP) => selected = selected.saturating_sub(1),
Key::Special(ScanCode::DOWN) => selected = (selected + 1).min(last),
Key::Special(ScanCode::HOME) => selected = 0,
Key::Special(ScanCode::END) => selected = last,
Key::Special(ScanCode::PAGE_UP) => selected = selected.saturating_sub(layout.visible),
Key::Special(ScanCode::PAGE_DOWN) => selected = (selected + layout.visible).min(last),
// Serial consoles may send a line feed instead of a carriage return.
Key::Printable(c) if matches!(char::from(c), '\r' | '\n') => return Ok(selected),
_ => {}
}
}
}
/// Selects an entry from `entries` using the console `input` and `output`.
fn select_with_console<'a>(
input: &mut Input,
output: &mut Output,
timeout: Option<Duration>,
entries: &'a [BootableEntry],
) -> Result<&'a BootableEntry> {
let default = entries
.iter()
.position(|entry| entry.is_default())
.context("no default entry available")?;
// If the timeout is zero, boot the default entry without showing the menu.
if timeout.is_some_and(|timeout| timeout.is_zero()) {
return Ok(&entries[default]);
}
let layout = Layout::new(output, entries);
let cursor_visible = output.cursor_visible();
output
.set_color(Color::LightGray, Color::Black)
.context("unable to set console color")?;
output.clear().context("unable to clear console")?;
// Not all consoles can hide the cursor, so this is allowed to fail.
let _ = output.enable_cursor(false);
let result = run(input, output, &layout, timeout, entries, default);
// Put the console back so that anything printed after the menu is readable.
let _ = output.set_color(Color::LightGray, Color::Black);
let _ = output.clear();
let _ = output.enable_cursor(cursor_visible);
Ok(&entries[result?])
}
impl BootMenu for SimpleMenu {
/// Selects an entry using the simple menu.
/// The actual work is done internally in [select_with_console] which is called
/// within the context of the standard input and output devices.
fn select<'a>(
&self,
timeout: Option<Duration>,
entries: &'a [BootableEntry],
) -> Result<&'a BootableEntry> {
uefi::system::with_stdin(|input| {
uefi::system::with_stdout(|output| select_with_console(input, output, timeout, entries))
})
}
}
+106 -67
View File
@@ -1,11 +1,12 @@
use alloc::string::{String, ToString};
use anyhow::{Context, Result, anyhow};
use anyhow::{Context, Result, anyhow, bail};
use core::cell::Cell;
use core::ptr::null_mut;
use edera_sprout_config::MenuStyle;
use jaarg::{
ErrorUsageWriter, ErrorUsageWriterContext, HelpWriter, HelpWriterContext, Opt, Opts,
ParseControl, ParseResult, StandardErrorUsageWriter, StandardFullHelpWriter,
HelpWriter, HelpWriterContext, Opt, Opts, ParseControl, ParseResult, StandardFullHelpWriter,
};
use log::{error, info};
use log::{info, warn};
use uefi_raw::Status;
/// Default configuration file path.
@@ -24,6 +25,8 @@ pub struct SproutOptions {
pub force_menu: bool,
/// The timeout for the boot menu in seconds.
pub menu_timeout: Option<u64>,
/// The style of boot menu to display.
pub menu_style: Option<MenuStyle>,
/// Retains the boot console before boot.
pub retain_boot_console: bool,
}
@@ -37,6 +40,7 @@ impl Default for SproutOptions {
boot: None,
force_menu: false,
menu_timeout: None,
menu_style: None,
retain_boot_console: false,
}
}
@@ -54,6 +58,7 @@ impl SproutOptions {
Boot,
ForceMenu,
MenuTimeout,
MenuStyle,
RetainBootConsole,
}
@@ -69,78 +74,112 @@ impl SproutOptions {
Opt::flag(ArgID::ForceMenu, &["--force-menu"]).help_text("Force showing the boot menu"),
Opt::value(ArgID::MenuTimeout, &["--menu-timeout"], "TIMEOUT")
.help_text("Boot menu timeout, in seconds"),
Opt::value(ArgID::MenuStyle, &["--menu-style"], "STYLE")
.help_text("Boot menu style, basic or simple"),
Opt::flag(ArgID::RetainBootConsole, &["--retain-boot-console"])
.help_text("Retain boot console before boot"),
]);
// Acquire the arguments as determined by the UEFI core.
let args = eficore::env::args()?;
let mut args = eficore::env::args()?;
// Use the default value of sprout options and have the raw options be parsed into it.
let mut result = Self::default();
// Parse the arguments, dropping any argument that can't be parsed and parsing again.
// A typo in a boot entry or junk from the firmware should not prevent booting.
loop {
// Use the default value of sprout options and have the raw options be parsed into it.
let mut result = Self::default();
// Parse the OPTIONS into a map using jaarg.
match OPTIONS.parse(
"sprout",
args.iter(),
|program_name, id, _opt, _name, value| {
match id {
ArgID::AutoConfigure => {
// Enable autoconfiguration.
result.autoconfigure = true;
}
ArgID::Config => {
// The configuration file to load.
result.config = value.into();
}
ArgID::Boot => {
// The entry to boot.
result.boot = Some(value.into());
}
ArgID::ForceMenu => {
// Force showing of the boot menu.
result.force_menu = true;
}
ArgID::MenuTimeout => {
// The timeout for the boot menu in seconds.
result.menu_timeout = Some(value.parse::<u64>()?);
}
ArgID::RetainBootConsole => {
// Retain the boot console before booting.
result.retain_boot_console = true;
}
ArgID::Help => {
let ctx = HelpWriterContext {
options: &OPTIONS,
program_name,
};
info!("{}", StandardFullHelpWriter::new(ctx));
return Ok(ParseControl::Quit);
// The number of arguments handed to the parser, used to find the one it rejected.
let consumed = Cell::new(0usize);
// The reason the parser rejected an argument, if it did.
let mut rejection = None;
// Parse the OPTIONS into a map using jaarg.
let parsed = OPTIONS.parse(
"sprout",
args.iter().inspect(|_| consumed.set(consumed.get() + 1)),
|program_name, id, _opt, name, value| {
match id {
ArgID::AutoConfigure => {
// Enable autoconfiguration.
result.autoconfigure = true;
}
ArgID::Config => {
// The configuration file to load.
result.config = value.into();
}
ArgID::Boot => {
// The entry to boot.
result.boot = Some(value.into());
}
ArgID::ForceMenu => {
// Force showing of the boot menu.
result.force_menu = true;
}
ArgID::MenuTimeout => {
// The timeout for the boot menu in seconds.
match value.parse::<u64>() {
Ok(timeout) => result.menu_timeout = Some(timeout),
Err(error) => {
warn!("ignoring invalid {} value '{}': {}", name, value, error)
}
}
}
ArgID::MenuStyle => {
// The style of boot menu to display.
match value {
"basic" => result.menu_style = Some(MenuStyle::Basic),
"simple" => result.menu_style = Some(MenuStyle::Simple),
_ => warn!(
"ignoring invalid {} value '{}': expected basic or simple",
name, value
),
}
}
ArgID::RetainBootConsole => {
// Retain the boot console before booting.
result.retain_boot_console = true;
}
ArgID::Help => {
let ctx = HelpWriterContext {
options: &OPTIONS,
program_name,
};
info!("{}", StandardFullHelpWriter::new(ctx));
return Ok(ParseControl::Quit);
}
}
Ok(ParseControl::Continue)
},
|_program_name, error| {
rejection = Some(error.to_string());
},
);
match parsed {
ParseResult::ContinueSuccess => return Ok(result),
ParseResult::ExitSuccess => unsafe {
uefi::boot::exit(uefi::boot::image_handle(), Status::SUCCESS, 0, null_mut())
.context("unable to exit")?;
return Err(anyhow!("unable to exit"));
},
ParseResult::ExitError => {
// The parser stops at the argument it rejected, which makes it the last
// argument it was handed. This is also true of an option missing its value,
// which is only detected once the arguments run out.
let Some(index) = consumed.get().checked_sub(1).filter(|i| *i < args.len())
else {
bail!("unable to parse options: {}", rejection.unwrap_or_default());
};
let arg = args.remove(index);
warn!(
"ignoring argument '{}': {}",
arg,
rejection.unwrap_or_default()
);
}
Ok(ParseControl::Continue)
},
|program_name, error| {
let ctx = ErrorUsageWriterContext {
options: &OPTIONS,
program_name,
error,
};
error!("{}", StandardErrorUsageWriter::new(ctx));
},
) {
ParseResult::ContinueSuccess => Ok(result),
ParseResult::ExitSuccess => unsafe {
uefi::boot::exit(uefi::boot::image_handle(), Status::SUCCESS, 0, null_mut())
.context("unable to exit")?;
Err(anyhow!("unable to exit"))
},
ParseResult::ExitError => unsafe {
uefi::boot::exit(uefi::boot::image_handle(), Status::ABORTED, 0, null_mut())
.context("unable to exit")?;
Err(anyhow!("unable to exit"))
},
}
}
}
}
+5
View File
@@ -18,4 +18,9 @@ pub struct ChainloadConfiguration {
/// generally better and safer as it can support additional load options in the future.
#[serde(default, rename = "linux-initrd")]
pub linux_initrd: Option<String>,
/// An optional list of paths to Linux initrds to chain together.
/// The contents of each initrd are concatenated in order and loaded as a single initrd
/// using the same mechanism as `linux-initrd`. This cannot be used with `linux-initrd`.
#[serde(default, rename = "linux-initrd-chain")]
pub linux_initrd_chain: Vec<String>,
}
+5
View File
@@ -14,6 +14,11 @@ pub struct EderaConfiguration {
/// The path to the initrd to load for dom0.
#[serde(default)]
pub initrd: Option<String>,
/// The paths to the initrds to chain together for dom0.
/// The contents of each initrd are concatenated in order and loaded as a single initrd.
/// This cannot be used with `initrd`.
#[serde(default, rename = "initrd-chain")]
pub initrd_chain: Vec<String>,
/// The options to pass to the kernel.
#[serde(default, rename = "kernel-options")]
pub kernel_options: Vec<String>,
+77 -2
View File
@@ -28,7 +28,7 @@ pub const LATEST_VERSION: u32 = 1;
pub const DEFAULT_MENU_TIMEOUT_SECONDS: u64 = 10;
/// The Sprout configuration format.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, 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
@@ -76,7 +76,7 @@ pub struct RootConfiguration {
}
/// Options configuration for Sprout, used when the corresponding options are not specified.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct OptionsConfiguration {
/// The entry to mark as the default entry, instead of the first entry.
#[serde(rename = "default-entry", default)]
@@ -84,11 +84,55 @@ pub struct OptionsConfiguration {
/// The timeout of the boot menu.
#[serde(rename = "menu-timeout", default = "default_menu_timeout")]
pub menu_timeout: u64,
/// The style of boot menu to display.
#[serde(rename = "menu-style", default)]
pub menu_style: MenuStyle,
/// Enables autoconfiguration of Sprout based on the environment.
#[serde(default)]
pub autoconfigure: bool,
}
/// The default configuration, which matches an empty configuration file.
impl Default for RootConfiguration {
fn default() -> Self {
Self {
version: latest_version(),
options: Default::default(),
values: Default::default(),
drivers: Default::default(),
extractors: Default::default(),
actions: Default::default(),
entries: Default::default(),
generators: Default::default(),
phases: Default::default(),
}
}
}
/// The default options, which match an empty options section.
impl Default for OptionsConfiguration {
fn default() -> Self {
Self {
default_entry: None,
menu_timeout: default_menu_timeout(),
menu_style: Default::default(),
autoconfigure: false,
}
}
}
/// The style of boot menu to display.
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum MenuStyle {
/// A basic menu that prints the entries and selects them by number.
#[serde(rename = "basic")]
Basic,
/// A simple full-screen menu that selects entries with the arrow keys.
#[default]
#[serde(rename = "simple")]
Simple,
}
/// Get the latest version of the Sprout configuration format.
pub fn latest_version() -> u32 {
LATEST_VERSION
@@ -97,3 +141,34 @@ pub fn latest_version() -> u32 {
fn default_menu_timeout() -> u64 {
DEFAULT_MENU_TIMEOUT_SECONDS
}
#[cfg(test)]
mod tests {
use super::*;
use serde::de::value::{Error, MapDeserializer};
/// Deserialize a configuration from an empty map, like an empty configuration file.
fn empty() -> RootConfiguration {
let map = MapDeserializer::<_, Error>::new(core::iter::empty::<(&str, &str)>());
RootConfiguration::deserialize(map).expect("empty configuration should deserialize")
}
#[test]
fn default_matches_empty_configuration() {
let default = RootConfiguration::default();
let empty = empty();
assert_eq!(default.version, empty.version);
assert_eq!(default.options.menu_timeout, empty.options.menu_timeout);
assert_eq!(default.options.menu_style, empty.options.menu_style);
assert_eq!(default.options.default_entry, empty.options.default_entry);
assert_eq!(default.options.autoconfigure, empty.options.autoconfigure);
}
#[test]
fn default_menu_timeout_is_not_zero() {
assert_eq!(
RootConfiguration::default().options.menu_timeout,
DEFAULT_MENU_TIMEOUT_SECONDS
);
}
}
+27 -15
View File
@@ -17,9 +17,12 @@ mod bitflags;
const LOADER_NAME: &str = "Sprout";
/// Represents the configured timeout for the bootloader interface.
#[derive(Default)]
pub enum BootloaderInterfaceTimeout {
/// Force the menu to be shown.
/// Force the menu to be shown, and wait for the user.
MenuForce,
/// Force the menu to be shown, with a timeout.
MenuForceTimeout(u64),
/// Hide the menu.
MenuHidden,
/// Disable the menu.
@@ -27,6 +30,7 @@ pub enum BootloaderInterfaceTimeout {
/// Set a timeout for the menu.
Timeout(u64),
/// Timeout is unspecified.
#[default]
Unspecified,
}
@@ -219,10 +223,9 @@ impl BootloaderInterface {
// If we reach here, we know the value was specified.
// If `remove` is true, remove the variable.
if remove {
Self::VENDOR
.remove(key)
.context("unable to remove timeout variable")?;
// If removal fails, the value is still used, as it was meant for this boot.
if remove && let Err(error) = Self::VENDOR.remove(key) {
warn!("unable to remove {} variable: {:#}", key, error);
}
// If the value is empty, return Unspecified.
@@ -255,12 +258,8 @@ impl BootloaderInterface {
}
};
// The specification says that a value of 0 means that the menu should be hidden.
if value == 0 {
return Ok(Some(BootloaderInterfaceTimeout::MenuHidden));
}
// If we reach here, we know it must be a real timeout value.
// A value of 0 is left to the caller, as its meaning depends on the variable.
Ok(Some(BootloaderInterfaceTimeout::Timeout(value)))
}
@@ -274,8 +273,16 @@ impl BootloaderInterface {
.context("unable to check for LoaderConfigTimeoutOneShot variable")?;
// If oneshot was found, return it.
// The specification says the one-shot timeout shows the menu on this boot,
// and a value of 0 means the menu waits for the user.
if let Some(oneshot) = oneshot {
return Ok(oneshot);
return Ok(match oneshot {
BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuForce,
BootloaderInterfaceTimeout::Timeout(value) => {
BootloaderInterfaceTimeout::MenuForceTimeout(value)
}
other => other,
});
}
// Attempt to acquire the value of the LoaderConfigTimeout variable.
@@ -284,8 +291,12 @@ impl BootloaderInterface {
.context("unable to check for LoaderConfigTimeout variable")?;
// If direct was found, return it.
// The specification says that a value of 0 means that the menu should be hidden.
if let Some(direct) = direct {
return Ok(direct);
return Ok(match direct {
BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuHidden,
other => other,
});
}
// If we reach here, we know that neither variable was set.
@@ -315,9 +326,10 @@ impl BootloaderInterface {
};
// Remove the oneshot entry from the bootloader interface.
Self::VENDOR
.remove("LoaderEntryOneShot")
.context("unable to remove oneshot entry")?;
// If removal fails, the value is still used, as it was meant for this boot.
if let Err(error) = Self::VENDOR.remove("LoaderEntryOneShot") {
warn!("unable to remove LoaderEntryOneShot variable: {:#}", error);
}
// Return the oneshot value.
// An empty value is treated as if the oneshot entry was not set.
+18 -19
View File
@@ -1,7 +1,7 @@
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use anyhow::{Context, Result, bail};
use uefi::proto::loaded_image::{LoadOptionsError, LoadedImage};
use anyhow::{Context, Result};
use uefi::proto::loaded_image::LoadedImage;
/// Loads the command-line arguments passed to the current image.
pub fn args() -> Result<Vec<String>> {
@@ -13,26 +13,25 @@ pub fn args() -> Result<Vec<String>> {
.context("unable to open loaded image protocol for current image")?;
// Load the command-line argument string.
let options = match loaded_image.load_options_as_cstr16() {
// Load options were passed. We will return them for processing.
Ok(options) => options,
// Load options are usually a null-terminated UCS-2 string, but firmware is not consistent
// about this. Some firmware passes an odd number of bytes, leaves off the null terminator,
// or pads the string with extra nulls. We decode everything up to the first null and
// replace anything that isn't valid UCS-2, instead of refusing to boot.
let Some(options) = loaded_image.load_options_as_bytes() else {
// No load options were passed. We will return an empty vector.
Err(LoadOptionsError::NotSet) => {
return Ok(Vec::new());
}
Err(LoadOptionsError::NotAligned) => {
bail!("load options are not properly aligned");
}
Err(LoadOptionsError::InvalidString(error)) => {
bail!("load options are not a valid string: {}", error);
}
return Ok(Vec::new());
};
// Convert the options to a string.
let options = options.to_string();
// Decode the options as little-endian UTF-16, stopping at the first null.
// A trailing odd byte can't be part of a character, so it is dropped.
let options = options
.as_chunks::<2>()
.0
.iter()
.map(|pair| u16::from_le_bytes(*pair))
.take_while(|c| *c != 0)
.collect::<Vec<u16>>();
let options = String::from_utf16_lossy(&options);
// Use shlex to parse the options.
// If shlex fails, we will perform a simple whitespace split.
+2 -2
View File
@@ -70,8 +70,8 @@ impl MediaLoaderHandle {
}
// Boot policy must not be true, and if it is, that is special behavior that is irrelevant
// for the media loader concept.
if boot_policy == Boolean::TRUE {
// for the media loader concept. Any non-zero value is true.
if bool::from(boot_policy) {
return Status::UNSUPPORTED;
}
+23 -39
View File
@@ -6,7 +6,9 @@ use anyhow::{Context, Result};
use core::ops::Deref;
use uefi::fs::{FileSystem, Path};
use uefi::proto::device_path::text::{AllowShortcuts, DevicePathFromText, DisplayOnly};
use uefi::proto::device_path::{DevicePath, PoolDevicePath};
use uefi::proto::device_path::{
DevicePath, DevicePathNode, DeviceSubType, DeviceType, PoolDevicePath,
};
use uefi::proto::media::fs::SimpleFileSystem;
use uefi::{CString16, Handle};
@@ -40,10 +42,17 @@ impl ResolvedPath {
}
}
/// 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)
/// Checks if the device path `node` is a file path node, rather than a device node.
fn is_file_path_node(node: &DevicePathNode) -> bool {
node.device_type() == DeviceType::MEDIA && node.sub_type() == DeviceSubType::MEDIA_FILE_PATH
}
/// Converts the device path `node` to its text form.
fn node_to_string(node: &DevicePathNode) -> Result<String> {
Ok(node
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert device path node to string")?
.to_string())
}
/// Parses the input `path` as a [DevicePath].
@@ -67,20 +76,9 @@ pub fn text_to_device_path(path: impl AsRef<str>) -> Result<PoolDevicePath> {
pub fn device_path_root(path: &DevicePath) -> Result<String> {
let mut path = path
.node_iter()
.filter_map(|item| {
let item = item.to_string16(DisplayOnly(false), AllowShortcuts(false));
if item
.as_ref()
.map(|item| cstring16_contains_char(item, '('))
.unwrap_or(false)
{
Some(item.unwrap_or_default())
} else {
None
}
})
.map(|item| item.to_string())
.collect::<Vec<_>>()
.filter(|node| !is_file_path_node(node))
.map(node_to_string)
.collect::<Result<Vec<_>>>()?
.join("/");
path.push('/');
Ok(path)
@@ -92,20 +90,9 @@ pub fn device_path_root(path: &DevicePath) -> Result<String> {
pub fn device_path_subpath(path: &DevicePath) -> Result<String> {
let path = path
.node_iter()
.filter_map(|item| {
let item = item.to_string16(DisplayOnly(false), AllowShortcuts(false));
if item
.as_ref()
.map(|item| cstring16_contains_char(item, '('))
.unwrap_or(false)
{
None
} else {
Some(item.unwrap_or_default())
}
})
.map(|item| item.to_string())
.collect::<Vec<_>>()
.filter(|node| is_file_path_node(node))
.map(node_to_string)
.collect::<Result<Vec<_>>>()?
.join("\\");
Ok(path)
}
@@ -120,15 +107,12 @@ pub fn resolve_path(
let mut input = input.to_string();
let mut path = text_to_device_path(&input).context("unable to convert text to path")?;
// A path that starts with a device node has a device, while a path that starts
// with a file path node is relative to the default root path.
let path_has_device = path
.node_iter()
.next()
.map(|it| {
it.to_string16(DisplayOnly(false), AllowShortcuts(false))
.unwrap_or_default()
})
.map(|it| it.to_string().contains('('))
.unwrap_or(false);
.is_some_and(|node| !is_file_path_node(node));
if !path_has_device {
if !input.starts_with('\\') {
input.insert(0, '\\');
+8 -2
View File
@@ -2,6 +2,7 @@
// Architecturally modified to support UEFI and remove x86 (32-bit) support.
use core::time::Duration;
use log::warn;
/// Support for aarch64 timers.
#[cfg(target_arch = "aarch64")]
@@ -35,7 +36,11 @@ impl TickFrequency {
}
/// Produce a duration from the provided elapsed `ticks` value.
/// If the frequency is unknown, the duration is zero.
fn duration(&self, ticks: u64) -> Duration {
if self.ticks() == 0 {
return Duration::ZERO;
}
let accuracy = self.nanos();
let nanos = ticks as f64 * accuracy;
Duration::from_nanos(nanos as u64)
@@ -56,9 +61,10 @@ fn arch_frequency() -> TickFrequency {
let frequency = aarch64::frequency();
#[cfg(target_arch = "x86_64")]
let frequency = x86_64::frequency();
// If the frequency is 0, then something went very wrong and we should panic.
// Some firmware does not program the timer frequency. The timer is only used to report
// timestamps to the bootloader interface, so this should not prevent booting.
if frequency.ticks() == 0 {
panic!("timer frequency is zero");
warn!("timer frequency is zero, timestamps will not be reported");
}
frequency
}
+23 -6
View File
@@ -1,8 +1,10 @@
use anyhow::{Context, Result};
use log::warn;
use uefi::ResultExt;
use uefi::boot::ScopedProtocol;
use uefi::proto::tcg::PcrIndex;
use uefi::proto::tcg::v2::{PcrEventInputs, Tcg};
use uefi_raw::Status;
use uefi_raw::protocol::tcg::EventType;
use uefi_raw::protocol::tcg::v2::{Tcg2HashLogExtendEventFlags, Tcg2Protocol, Tcg2Version};
@@ -121,18 +123,33 @@ impl PlatformTpm {
};
// Encode the description as UTF-8.
let description = description.as_bytes().to_vec();
let encoded_description = description.as_bytes().to_vec();
// Construct an event input for the TPM.
let event = PcrEventInputs::new_in_box(pcr_index, EventType::IPL, &description)
let event = PcrEventInputs::new_in_box(pcr_index, EventType::IPL, &encoded_description)
.discard_errdata()
.context("unable to construct pcr event inputs")?;
// Log the event into the TPM.
handle
.protocol()
.hash_log_extend_event(Tcg2HashLogExtendEventFlags::empty(), buffer, &event)
.context("unable to log event to tpm")?;
let result = handle.protocol().hash_log_extend_event(
Tcg2HashLogExtendEventFlags::empty(),
buffer,
&event,
);
// A full event log means the PCR was extended, but the event could not be logged.
// The measurement still happened, so this should not prevent booting.
if let Err(ref error) = result
&& error.status() == Status::VOLUME_FULL
{
warn!(
"tpm event log is full, unable to log event: {}",
description
);
return Ok(());
}
result.context("unable to log event to tpm")?;
Ok(())
}
}
+75 -44
View File
@@ -82,16 +82,71 @@ impl SecurityHook {
status.is_success()
}
/// Call the original EFI_SECURITY_ARCH hook with `this`, `status`, and `path`.
/// This lets the firmware decide on images that the shim can't verify.
unsafe fn original_file_authentication_state(
this: *const SecurityArchProtocol,
status: u32,
path: *const FfiDevicePath,
) -> Status {
// Acquire the global hook state to grab the original hook.
let function = match GLOBAL_HOOK_STATE.lock().as_ref() {
// The hook state is available, so we can acquire the original hook.
Some(state) => state.original_hook.file_authentication_state,
// The hook state is not available, so we can't call the original hook.
None => {
warn!("global hook state is not available, unable to call original hook");
return Status::LOAD_ERROR;
}
};
// Call the original hook function to see what it reports.
// SAFETY: This function is safe to call as it is stored by us and is required
// in the UEFI specification.
unsafe { function(this, status, path) }
}
/// Call the original EFI_SECURITY_ARCH2 hook with `this`, `path`, `file_buffer`,
/// `file_size`, and `boot_policy`.
/// This lets the firmware decide on images that the shim can't verify.
unsafe fn original_file_authentication(
this: *const SecurityArch2Protocol,
path: *const FfiDevicePath,
file_buffer: *const u8,
file_size: usize,
boot_policy: Boolean,
) -> Status {
// Acquire the global hook state to grab the original hook.
let function = match GLOBAL_HOOK_STATE.lock().as_ref() {
// The hook state is available, so we can acquire the original hook.
Some(state) => state.original_hook2.file_authentication,
// The hook state is not available, so we can't call the original hook.
None => {
warn!("global hook state is not available, unable to call original hook");
return Status::LOAD_ERROR;
}
};
// Call the original hook function to see what it reports.
// SAFETY: This function is safe to call as it is stored by us and is required
// in the UEFI specification.
unsafe { function(this, path, file_buffer, file_size, boot_policy) }
}
/// File authentication state verifier for the EFI_SECURITY_ARCH protocol.
/// Takes the `path` and determines the verification.
/// Anything the shim can't verify is passed to the original hook.
unsafe extern "efiapi" fn arch_file_authentication_state(
this: *const SecurityArchProtocol,
status: u32,
path: *const FfiDevicePath,
) -> Status {
// Verify the path is not null.
// Without a path, there is nothing for the shim to read and verify.
if path.is_null() {
return Status::INVALID_PARAMETER;
// SAFETY: The arguments are passed through unchanged from the firmware.
return unsafe { Self::original_file_authentication_state(this, status, path) };
}
// Construct a shim input from the path.
@@ -100,31 +155,19 @@ impl SecurityHook {
// Convert the input to an owned data buffer.
let input = match input.into_owned_data_buffer() {
Ok(input) => input,
// If an error occurs, log the error and return the not found status.
// If the data can't be read, such as for a path the shim can't access,
// let the original hook decide.
Err(error) => {
warn!("unable to read data to be authenticated: {}", error);
return Status::NOT_FOUND;
// SAFETY: The arguments are passed through unchanged from the firmware.
return unsafe { Self::original_file_authentication_state(this, status, path) };
}
};
// Verify the input, if it fails, call the original hook.
if !Self::verify(input) {
// Acquire the global hook state to grab the original hook.
let function = match GLOBAL_HOOK_STATE.lock().as_ref() {
// The hook state is available, so we can acquire the original hook.
Some(state) => state.original_hook.file_authentication_state,
// The hook state is not available, so we can't call the original hook.
None => {
warn!("global hook state is not available, unable to call original hook");
return Status::LOAD_ERROR;
}
};
// Call the original hook function to see what it reports.
// SAFETY: This function is safe to call as it is stored by us and is required
// in the UEFI specification.
unsafe { function(this, status, path) }
// SAFETY: The arguments are passed through unchanged from the firmware.
unsafe { Self::original_file_authentication_state(this, status, path) }
} else {
Status::SUCCESS
}
@@ -132,6 +175,7 @@ impl SecurityHook {
/// File authentication verifier for the EFI_SECURITY_ARCH2 protocol.
/// Takes the `path` and a file buffer to determine the verification.
/// Anything the shim can't verify is passed to the original hook.
unsafe extern "efiapi" fn arch2_file_authentication(
this: *const SecurityArch2Protocol,
path: *const FfiDevicePath,
@@ -139,14 +183,13 @@ impl SecurityHook {
file_size: usize,
boot_policy: Boolean,
) -> Status {
// Verify the path and file buffer are not null.
if path.is_null() || file_buffer.is_null() {
return Status::INVALID_PARAMETER;
}
// If the boot policy is true, we can't continue as we don't support that.
if bool::from(boot_policy) {
return Status::INVALID_PARAMETER;
// The path and file buffer are optional, and the shim can't verify without them.
// The shim also doesn't support the boot policy.
if path.is_null() || file_buffer.is_null() || bool::from(boot_policy) {
// SAFETY: The arguments are passed through unchanged from the firmware.
return unsafe {
Self::original_file_authentication(this, path, file_buffer, file_size, boot_policy)
};
}
// Construct a slice out of the file buffer and size.
@@ -157,22 +200,10 @@ impl SecurityHook {
// Verify the input, if it fails, call the original hook.
if !Self::verify(input) {
// Acquire the global hook state to grab the original hook.
let function = match GLOBAL_HOOK_STATE.lock().as_ref() {
// The hook state is available, so we can acquire the original hook.
Some(state) => state.original_hook2.file_authentication,
// The hook state is not available, so we can't call the original hook.
None => {
warn!("global hook state is not available, unable to call original hook");
return Status::LOAD_ERROR;
}
};
// Call the original hook function to see what it reports.
// SAFETY: This function is safe to call as it is stored by us and is required
// in the UEFI specification.
unsafe { function(this, path, file_buffer, file_size, boot_policy) }
// SAFETY: The arguments are passed through unchanged from the firmware.
unsafe {
Self::original_file_authentication(this, path, file_buffer, file_size, boot_policy)
}
} else {
Status::SUCCESS
}
+80 -10
View File
@@ -90,6 +90,18 @@ pub fn combine_options<T: AsRef<str>>(options: impl Iterator<Item = T>) -> Strin
.join(" ")
}
/// The alignment of each initrd when initrds are concatenated.
const INITRD_ALIGNMENT: usize = 4;
/// Append the `initrd` to the concatenated `initrds`, padding with zeros so that it starts
/// on a 4-byte boundary. Linux only finds a cpio archive at a 4-byte aligned offset when
/// unpacking concatenated initrds, as GRUB and systemd-boot pad them the same way.
pub fn append_initrd(initrds: &mut Vec<u8>, initrd: &[u8]) {
let padded = initrds.len().next_multiple_of(INITRD_ALIGNMENT);
initrds.resize(padded, 0);
initrds.extend_from_slice(initrd);
}
/// Produce a unique hash for the input.
/// This uses SHA-256, which is unique enough but relatively short.
pub fn unique_hash(input: &str) -> String {
@@ -140,14 +152,24 @@ pub fn match_kernel_prefix<'a>(name: &str, kernel_prefixes: &[&'a str]) -> Optio
}
/// Generate initramfs candidate filenames by combining each entry of `initramfs_prefixes`
/// with `suffix`. The caller is expected to check which candidates actually exist.
pub fn initramfs_candidates<'a>(
suffix: &'a str,
initramfs_prefixes: &'a [&'a str],
) -> impl Iterator<Item = String> + 'a {
initramfs_prefixes
.iter()
.map(move |prefix| format!("{}{}", prefix, suffix))
/// with `suffix`, and then with `suffix` followed by `.img`, which many distributions use.
/// For example, Arch Linux pairs `vmlinuz-linux` with `initramfs-linux.img`.
/// The caller is expected to check which candidates actually exist.
pub fn initramfs_candidates(
suffix: &str,
initramfs_prefixes: &[&str],
) -> impl Iterator<Item = String> {
let mut candidates: Vec<String> = Vec::new();
for extension in ["", ".img"] {
for prefix in initramfs_prefixes {
let candidate = format!("{}{}{}", prefix, suffix, extension);
// Skip duplicates, like "initrd" with ".img" when "initrd.img" is also a prefix.
if !candidates.contains(&candidate) {
candidates.push(candidate);
}
}
}
candidates.into_iter()
}
#[cfg(test)]
@@ -348,6 +370,24 @@ mod tests {
assert!(config.ends_with('\n'));
}
#[test]
fn append_initrd_pads_to_four_bytes() {
let mut initrds = Vec::new();
append_initrd(&mut initrds, b"abcde");
assert_eq!(initrds, b"abcde");
append_initrd(&mut initrds, b"fg");
assert_eq!(initrds, b"abcde\0\0\0fg");
append_initrd(&mut initrds, b"h");
assert_eq!(initrds, b"abcde\0\0\0fg\0\0h");
}
#[test]
fn append_initrd_aligned_needs_no_padding() {
let mut initrds = b"abcd".to_vec();
append_initrd(&mut initrds, b"efgh");
assert_eq!(initrds, b"abcdefgh");
}
#[test]
fn kernel_prefix_exact_match() {
assert_eq!(
@@ -399,14 +439,44 @@ mod tests {
let candidates: Vec<_> = initramfs_candidates("-6.1.0", LINUX_INITRAMFS_PREFIXES).collect();
assert_eq!(
candidates,
&["initramfs-6.1.0", "initrd-6.1.0", "initrd.img-6.1.0"]
&[
"initramfs-6.1.0",
"initrd-6.1.0",
"initrd.img-6.1.0",
"initramfs-6.1.0.img",
"initrd-6.1.0.img",
"initrd.img-6.1.0.img",
]
);
}
#[test]
fn initramfs_candidates_include_img_extension() {
// Arch Linux, and Fedora or Gentoo with dracut, add .img to the initramfs name.
let arch: Vec<_> = initramfs_candidates("-linux", LINUX_INITRAMFS_PREFIXES).collect();
assert!(arch.iter().any(|c| c == "initramfs-linux.img"));
let fedora: Vec<_> =
initramfs_candidates("-6.8.5-301.fc40.x86_64", LINUX_INITRAMFS_PREFIXES).collect();
assert!(
fedora
.iter()
.any(|c| c == "initramfs-6.8.5-301.fc40.x86_64.img")
);
}
#[test]
fn initramfs_candidates_empty_suffix() {
let candidates: Vec<_> = initramfs_candidates("", LINUX_INITRAMFS_PREFIXES).collect();
assert_eq!(candidates, &["initramfs", "initrd", "initrd.img"]);
assert_eq!(
candidates,
&[
"initramfs",
"initrd",
"initrd.img",
"initramfs.img",
"initrd.img.img"
]
);
}
#[test]
+1 -1
View File
@@ -55,7 +55,7 @@ $ cp /usr/lib/shim/fbaa64.efi.signed /boot/efi/EFI/sprout/fbaa64.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 ARM64 systems, download the `sprout-aarch64.efi` file.
Copy the downloaded `sprout.efi` file to `/boot/efi/EFI/sprout/sprout.unsigned.efi` on your EFI System Partition.
Copy the downloaded file to `/boot/efi/EFI/sprout/sprout.unsigned.efi` on your EFI System Partition.
## Step 4: Sign Sprout for Secure Boot
+1 -1
View File
@@ -55,7 +55,7 @@ $ cp /boot/efi/EFI/fedora/mmaa64.efi /boot/efi/EFI/sprout/mmaa64.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 ARM64 systems, download the `sprout-aarch64.efi` file.
Copy the downloaded `sprout.efi` file to `/boot/efi/EFI/sprout/sprout.unsigned.efi` on your EFI System Partition.
Copy the downloaded file to `/boot/efi/EFI/sprout/sprout.unsigned.efi` on your EFI System Partition.
## Step 4: Sign Sprout for Secure Boot
+4 -2
View File
@@ -60,7 +60,7 @@ $ cp /usr/share/efi/aarch64/fallback.efi /boot/efi/EFI/sprout/fallback.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 ARM64 systems, download the `sprout-aarch64.efi`
file.
Copy the downloaded `sprout.efi` file to `/boot/efi/EFI/sprout/sprout.unsigned.efi` on your EFI System Partition.
Copy the downloaded file to `/boot/efi/EFI/sprout/sprout.unsigned.efi` on your EFI System Partition.
## Step 4: Sign Sprout for Secure Boot
@@ -78,8 +78,10 @@ $ sbsign \
You will need a filesystem EFI driver if `/boot` is not FAT32 or ExFAT.
If `/boot` is FAT32 or ExFAT, you can skip this step.
Most Debian systems use an ext4 filesystem for `/boot`.
openSUSE puts `/boot` on a Btrfs filesystem by default, but it may be ext4 or another filesystem depending on how
the system was installed. This guide uses an ext4 driver as the example, so use the driver for your filesystem instead.
You can download an EFI filesystem driver from [EfiFs releases](https://github.com/pbatard/EfiFs/releases).
For Btrfs, download the `btrfs` file for your platform.
For ext4, download the `ext2` file for your platform. It should work for ext4 filesystems too.
If you have an EFI driver, copy the driver to `/boot/efi/EFI/sprout/DRIVER_NAME.unsigned.efi` for signing.
+1 -3
View File
@@ -54,7 +54,7 @@ $ cp /usr/lib/shim/fbaa64.efi /boot/efi/EFI/sprout/fbaa64.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 ARM64 systems, download the `sprout-aarch64.efi` file.
Copy the downloaded `sprout.efi` file to `/boot/efi/EFI/sprout/sprout.unsigned.efi` on your EFI System Partition.
Copy the downloaded file to `/boot/efi/EFI/sprout/sprout.unsigned.efi` on your EFI System Partition.
## Step 4: Sign Sprout for Secure Boot
@@ -129,8 +129,6 @@ If you do not have any drivers, exclude the drivers section entirely.
## Step 7: Configure Sprout Boot Entry
## Step 7: Configure Sprout Boot Entry
In the following commands, replace /dev/BLOCK_DEVICE with the device that houses your GPT partition table,
and PARTITION_NUMBER with the partition number of the EFI System Partition. For example, if your EFI System Partition is
`/dev/sda1`, the BLOCK_DEVICE would be `/dev/sda` and the PARTITION_NUMBER would be `1`
+6 -4
View File
@@ -10,13 +10,13 @@
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.
Copy the downloaded 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
$ apk add efifs
```
## Step 2: Configure Sprout
@@ -102,10 +102,12 @@ 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:
Once `efibootmgr` is installed, find the disk and partition number of your EFI System Partition and run the following.
For example, if your EFI System Partition is `/dev/nvme0n1p1`, the disk is `/dev/nvme0n1` and the partition
number is `1`:
```bash
$ efibootmgr -d /dev/esp_partition_here -C -L 'Sprout' -l '\EFI\boot\sprout.efi'
$ efibootmgr -d /dev/BLOCK_DEVICE -p PARTITION_NUMBER -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.
+6 -4
View File
@@ -10,7 +10,7 @@
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.
Copy the downloaded file to `/boot/efi/EFI/BOOT/sprout.efi` on your EFI System Partition.
Additionally, you will want to install the `edk2-ext4` package, which provides the ext4 filesystem support for Sprout.
@@ -67,7 +67,7 @@ $ [ ! -d /boot/grub2/x86_64-efi ] && sudo cp -r /usr/lib/grub/x86_64-efi /boot/g
# Install ARM64 GRUB modules.
$ sudo dnf install grub2-efi-aa64-modules
# Copy ARM64 GRUB modules to /boot/grub2 for use by GRUB if it isn't installed already.
$ [ ! -d /boot/grub2/arm64-efi ] && sudo cp -r /usr/lib/grub/arm64-efi /boot/grub2/x86_64-efi
$ [ ! -d /boot/grub2/arm64-efi ] && sudo cp -r /usr/lib/grub/arm64-efi /boot/grub2/arm64-efi
```
You will need to find the UUID of your EFI System Partition. You can do this by running the following command:
@@ -107,10 +107,12 @@ You may now reboot your system and select Sprout from the GRUB menu.
You can configure your EFI boot menu to show Sprout as an option.
To do so, please find the partition device of your EFI System Partition and run the following:
To do so, please find the disk and partition number of your EFI System Partition and run the following.
For example, if your EFI System Partition is `/dev/nvme0n1p1`, the disk is `/dev/nvme0n1` and the partition
number is `1`:
```bash
$ sudo efibootmgr -d /dev/esp_partition_here -C -L 'Sprout' -l '\EFI\BOOT\sprout.efi'
$ sudo efibootmgr -d /dev/BLOCK_DEVICE -p PARTITION_NUMBER -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.
+5 -3
View File
@@ -12,7 +12,7 @@ First, identify the path to your EFI System Partition. On most systems, this is
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.
Copy the downloaded file to `/EFI/BOOT/sprout.efi` on your EFI System Partition.
## Step 2: Copy kernel and optional initramfs
@@ -52,10 +52,12 @@ They will be concatenated by a space and passed to the kernel.
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:
To add the entry, please find the disk and partition number of your EFI System Partition and run the following.
For example, if your EFI System Partition is `/dev/nvme0n1p1`, the disk is `/dev/nvme0n1` and the partition
number is `1`:
```bash
$ sudo efibootmgr -d /dev/esp_partition_here -C -L 'Sprout' -l '\EFI\BOOT\sprout.efi'
$ sudo efibootmgr -d /dev/BLOCK_DEVICE -p PARTITION_NUMBER -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.
+3 -3
View File
@@ -23,9 +23,9 @@ 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.
Copy the downloaded file to `X:\EFI\BOOT\sprout.efi` on your EFI System Partition.
## Step 3: Configure Sprout
## Step 2: Configure Sprout
Write the following file to `X:\sprout.toml`:
@@ -39,7 +39,7 @@ version = 1
autoconfigure = true
```
## Step 4: Configure EFI Firmware to boot Sprout
## Step 3: 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.
+1 -1
View File
@@ -40,7 +40,7 @@ if [ -z "${QEMU_ACCEL}" ] && [ "${TARGET_ARCH}" = "${HOST_ARCH}" ] && [ -e "/dev
QEMU_ACCEL="kvm"
fi
if [ "$(uname)" = "Darwin" ] && [ "${TARGET_ARCH}" = "${HOST_ARCH}" ] &&
if [ -z "${QEMU_ACCEL}" ] && [ "$(uname)" = "Darwin" ] && [ "${TARGET_ARCH}" = "${HOST_ARCH}" ] &&
[ "$(sysctl -n kern.hv_support 2>&1 || true)" = "1" ]; then
QEMU_ACCEL="hvf"
fi
+6 -1
View File
@@ -15,7 +15,12 @@ set --
if [ "${TARGET_ARCH}" = "x86_64" ]; then
set -- "${@}" qemu-system-x86_64 -M q35 -cpu SandyBridge,vmx=on
elif [ "${TARGET_ARCH}" = "aarch64" ]; then
set -- "${@}" qemu-system-aarch64 -M virt -cpu cortex-a57
# Hardware acceleration on aarch64 only supports the host CPU model.
if [ -n "${QEMU_ACCEL}" ] && [ "${QEMU_ACCEL}" != "tcg" ]; then
set -- "${@}" qemu-system-aarch64 -M virt -cpu host
else
set -- "${@}" qemu-system-aarch64 -M virt -cpu cortex-a57
fi
fi
if [ -n "${QEMU_ACCEL}" ]; then
+1
View File
@@ -105,6 +105,7 @@ if [ "${SKIP_SPROUT_BUILD}" != "1" ]; then
cp "target/${RUST_TARGET}/${RUST_TARGET_SUBDIR}/sprout.efi" "${FINAL_DIR}/sprout.efi"
else
docker build --platform="${DOCKER_TARGET}" -t "${DOCKER_PREFIX}/sprout-${TARGET_ARCH}:${DOCKER_TAG}" \
--build-arg="RUST_PROFILE=${RUST_PROFILE}" \
--build-arg="RUST_TARGET_SUBDIR=${RUST_TARGET_SUBDIR}" \
-f Dockerfile .
copy_from_image "${DOCKER_PREFIX}/sprout-${TARGET_ARCH}" "sprout.efi" "${FINAL_DIR}/sprout.efi"
+1 -1
View File
@@ -10,7 +10,7 @@ has-item = "\\EFI\\BOOT\\xen.efi"
[actions.boot-edera]
edera.xen = "$boot\\EFI\\BOOT\\xen.efi"
edera.xen-options = ["clocksource=tsc", "smp=on", "smt=on", "ioapic_ack=new", "dom0_vcpus_pin=on", "spec-ctrl=gds-mit=no", "noreboot", "console=com1"]
edera.kernel = "$boot\\EFI\\BOOT\\kernel.efi"
edera.kernel = "$boot\\vmlinuz"
edera.kernel-options = ["console=hvc0", "edera-xen=yes"]
edera.initrd = "$boot\\initramfs"
+2
View File
@@ -1,5 +1,7 @@
#!/bin/bash
set -e
# Without globstar, ** matches like *, so only scripts one directory deep are formatted.
shopt -s globstar
cd "$(dirname "${0}")/.." || exit 1