Compare commits

...
Author SHA1 Message Date
dependabot[bot] 045c3a3ca7 chore(deps): bump the actions-updates group across 1 directory with 5 updates
Bumps the actions-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.22.1` | `2.22.2` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.3.0` | `10.3.2` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.38.3` | `4.39.1` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.3` | `4.39.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.3` | `4.39.1` |



Updates `step-security/harden-runner` from 2.22.1 to 2.22.2
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/ccd8616d44fd3846e67624a50d5aad6d37bf2d25...dc2769abcc0fcfbadfc1df54081c1608579ef6b2)

Updates `astral-sh/setup-uv` from 10.3.0 to 10.3.2
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/1c37ad07a6a961277cf70c0d37d6f313000f5884...07a047e608db84b3d8c06cd9a7c7dcc9c355c18e)

Updates `github/codeql-action/upload-sarif` from 4.38.3 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/init` from 4.38.3 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.22.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-02 09:13:00 +00:00
alex 45e8dcf597 sprout: version 0.0.31 (#134) 2026-10-24 23:23:05 -07:00
dependabot[bot] ac47febd0c chore(deps): bump toml (#132)
Bumps the cargo-updates group with 1 update: [toml](https://github.com/toml-rs/toml).


Updates `toml` from 1.1.6+spec-1.1.0 to 1.1.8+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml-v1.1.6...toml-v1.1.8)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.8+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-24 23:13:30 -07:00
dependabot[bot] 5637753022 chore(deps): bump the actions-updates group with 7 updates (#133)
Bumps the actions-updates group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.22.0` | `2.22.1` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.2.0` | `10.3.0` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [actions/create-github-app-token](https://github.com/actions/create-github-app-token) | `3.2.0` | `3.3.0` |


Updates `step-security/harden-runner` from 2.22.0 to 2.22.1
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/351661ca32ac09a36dc5ee2d536e3128f2a3c8ed...ccd8616d44fd3846e67624a50d5aad6d37bf2d25)

Updates `astral-sh/setup-uv` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/c18668ad3cf93ea998bef934396af7bb5c839dc7...1c37ad07a6a961277cf70c0d37d6f313000f5884)

Updates `github/codeql-action/upload-sarif` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/init` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `actions/create-github-app-token` from 3.2.0 to 3.3.0
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/bcd2ba49218906704ab6c1aa796996da409d3eb1...64ab729a6294000f5aa4a4bc0b73d1f32bc45510)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.22.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: actions/create-github-app-token
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-24 23:13:12 -07:00
alex 86b7a6e5a5 feat(boot): reconnect only storage for filesystem drivers (#130)
Add a provides field to drivers, either any or filesystems. When every
driver only provides filesystems, only disk and partition controllers
are reconnected.
2026-10-16 12:33:35 -07:00
alex fe068c5f00 chore(ci): bump rust to 1.99.1 (#129) 2026-10-16 11:52:40 -07:00
dependabot[bot] 461a598d9e chore(deps): bump the actions-updates group across 1 directory with 4 updates (#128)
Bumps the actions-updates group with 4 updates in the / directory: [step-security/harden-runner](https://github.com/step-security/harden-runner), [github/codeql-action/upload-sarif](https://github.com/github/codeql-action), [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `step-security/harden-runner` from 2.21.1 to 2.22.0
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/e14015d583714f6e62063499dc959a02595150a1...351661ca32ac09a36dc5ee2d536e3128f2a3c8ed)

Updates `github/codeql-action/upload-sarif` from 4.38.1 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/1c5b675653bb5c22dbe9b12b556ec555138e09fd...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `github/codeql-action/init` from 4.38.1 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/1c5b675653bb5c22dbe9b12b556ec555138e09fd...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `github/codeql-action/analyze` from 4.38.1 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/1c5b675653bb5c22dbe9b12b556ec555138e09fd...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 11:47:41 -07:00
alex e2912b75ba docs(readme): drop beta from shipped features (#126)
* fix(boot): stop warning that Secure Boot is in beta

* docs(readme): drop beta from shipped features
2026-10-06 11:29:04 -07:00
alex 98f0298737 fix(boot): resolve values that refer to other values when stamping 2026-10-04 17:53:13 -07:00
alex ab9f3cd2d3 fix(boot): stamp generated entry actions with the final entry context 2026-10-04 17:53:13 -07:00
alex 5f81a99d21 fix(boot): keep the one-shot entry as default when the menu is forced 2026-10-04 17:53:13 -07:00
alex 7c4b2e87b0 fix(boot): boot a counted unified kernel image by its renamed file 2026-10-04 17:53:13 -07:00
alex 4198dfbb8c fix(eficore): keep backslashes in command-line arguments 2026-10-04 17:53:13 -07:00
alex 5be2c1b561 feat(boot): add enable-logo and enable-animation to the graphical menu (#123) 2026-10-04 14:36:35 -07:00
alex afc6d87b91 fix(boot): use more of the graphical menu for titles (#122) 2026-10-04 14:27:46 -07:00
alex c6cb96fb79 fix(boot): only redraw what changed in the graphical menu (#121)
Each frame redrew the whole screen and blitted the entire framebuffer. The static background and panel are now cached, and only the changed regions are blitted.
2026-10-04 14:22:43 -07:00
alex 808b162e55 docs(config): move CONFIG.md to docs/config.md (#120)
Keeps the configuration reference next to the setup guides. Updates the README, CLAUDE.md, the doc comment in crates/config and the update-config-docs skill to match.
2026-10-04 14:19:32 -07:00
alex ea115d3a16 docs(config): add CONFIG.md and move the configuration reference out of the README (#119)
CONFIG.md documents every section of sprout.toml, the command line options, BLS support and strict mode. The README keeps the overview and minimal examples and links to it. Adds an update-config-docs skill to keep the reference in sync with the code.
2026-10-04 14:14:13 -07:00
alex 9ac55ada50 fix(boot): make the graphical menu mouse opt-in
Add menu-styles.graphical.enable-mouse, which is off by default. Without it, or without a pointing device, the cursor is not drawn and the hint only mentions the keyboard.
2026-10-04 14:03:34 -07:00
alex ffbfcbc1f0 docs: add CLAUDE.md and document the release process 2026-10-04 12:32:37 -07:00
alex 612268b9d5 fix(ci): correct the version comment on the create-github-app-token pin 2026-10-04 11:29:05 -07:00
alex ce8f590b7b docs(readme): correct the strict mode table 2026-10-04 10:55:07 -07:00
alex 5a4bfefb6e fix(boot): count tries without using them, add XBOOTLDR once, and warn about dropped initrds
An entry that is not counted in strict mode still has its tries, so it
can be bad. Strict mode adds the extended boot loader partition to the
first BLS generator only. A chainload chain that does not list an initrd
that the entry has now warns, as the kernel may not find its root
without it, and strict autoconfigure says when it can't find the
partition of Sprout.
2026-10-04 10:55:07 -07:00
alex 47020c7b92 fix(bls): keep the Sprout name and version fields outside strict mode 2026-10-04 10:55:07 -07:00
alex cb9f05f199 docs(readme): document strict mode and the new precedence 2026-10-04 10:55:07 -07:00
alex 975df0457e fix(boot): show the path of a missing BLS file in the warning 2026-10-04 10:55:07 -07:00
alex 14463d5776 feat(boot): read only the boot partitions for BLS in strict mode
In strict mode autoconfigure reads BLS entries from the partition Sprout
was loaded from and the extended boot loader partition of its disk, with
one generator, and no longer makes entries from other disks. Other
filesystems that have BLS are still not scanned for Linux kernels.
2026-10-04 10:55:07 -07:00
alex ca01353df4 feat(boot): make strict mode follow systemd-boot's selection and menu rules
In strict mode a one-shot entry only becomes the default, the default
entry may be one with no boot counter tries left, no menu timeout means a
hidden menu, the menu is shown again when a started image returns, and
entries that mix linux, efi and uki, entries whose file is missing, and
unified kernel images with no name are hidden. Plain EFI entries are not
counted, the XBOOTLDR partition is always read, and a devicetree that
can't be installed fails the entry.

Outside of strict mode these keep working as they did, and each one is
now logged where it applies. The initrd limit of BLS entries is 32.
A devicetree that can't be installed is skipped with a warning.
2026-10-04 10:55:07 -07:00
alex bd37e9058c feat(bls): follow the systemd-boot fallbacks for unified kernel image names 2026-10-04 10:55:07 -07:00
alex 7e1207097b feat(boot): add the bls-strict-mode option
Add --bls-strict-mode and bls-strict-mode in the options of sprout.toml.
It does nothing yet; the behaviors it controls follow.
2026-10-04 10:55:07 -07:00
alex bfca43fe07 fix(boot): let the bootloader interface outrank the configuration
The default entry and the menu timeout that bootctl sets in LoaderEntryDefault,
LoaderEntryPreferred and LoaderConfigTimeout now win over default-entry and
menu-timeout in sprout.toml, as they do over loader.conf in systemd-boot.
--menu-timeout and the one-shot timeout still win over everything.
2026-10-04 10:55:07 -07:00
alex c6524b7bed feat(hack): add scripts to drive the dev environment headless
Start QEMU in the background with a QMP socket and console logs, take
screenshots, press keys and wait for serial output, so the graphical boot
menu can be driven without a terminal or a window. boot.sh gets opt-in
knobs for this and behaves as before otherwise.
2026-10-04 10:24:36 -07:00
alex 4e10d57143 fix(boot): tidy the devicetree, reboot and profile handling after review
Only warn about a devicetree under Secure Boot when the entry has one,
skip the fixup if its protocol can't be opened, and reject a devicetree
whose size is smaller than its header. Announce a reboot before the
delay, advertise the multi-profile UKI feature, and treat a Type 1 entry
with a profile after the first as an extra profile that is not picked as
the fallback default. A failure to read Sprout's own partition no longer
drops the entries of the XBOOTLDR partition, and the README lists every
initrd slot and describes the reboot behaviour more exactly.
2026-10-03 20:23:57 -07:00
alex 27c2d32900 docs(readme): describe the BLS, boot counting and loader.conf support
Update the feature list for what is implemented now, including the
graphical menu and the generators, add the command line options that
were missing, and document the BLS generator options, boot counting,
loader.conf, and the bootloader interface.
2026-10-03 20:23:57 -07:00
alex 1658ce0bef feat(bls): read the extended boot loader partition in the BLS generator
Add an xbootldr option to the BLS generator. When it is enabled, the
partition with the XBOOTLDR type on the same disk as Sprout's partition
is read as well, and its entries and unified kernel images are sorted
with the others. As their files are on that partition, every entry has
an entry-root value, which is the device of the partition or nothing for
Sprout's own, to use in a path like $entry-root\$chainload. It is off
by default, as existing actions would look for the files on the wrong
partition.
2026-10-03 20:23:57 -07:00
alex 50f62f5bb6 feat(bls): one entry for each profile of a unified kernel image
A unified kernel image can have several profiles, each started by a
.profile section and made of the sections after it on top of the base.
Every profile is an entry. The first has the id of the file, and the
others add @ and the identifier or the number of the profile, with a
title that names the profile and the profile number as the load options.
The profiles share the boot counter of the file, sort in the order they
are in the image, and a profile after the first is not picked as the
default entry when no default was asked for.

The PE reader keeps every section in order, so repeated names and the
sections that a profile removes can be told apart, and reads images with
more than 96 sections.
2026-10-03 20:23:57 -07:00
alex ac49899294 fix(eficore): log when the devicetree of the firmware is restored 2026-10-03 20:23:57 -07:00
alex ac4ec69c02 feat(boot): install the devicetree of a BLS entry
Add a devicetree option to the chainload action, set from the devicetree
key of a BLS entry. The file is installed as the EFI devicetree table
with the memory type that a devicetree has to be in, patched by the
fixup protocol when the firmware has one, and the table of the firmware
is put back when the image returns or fails to start. It is ignored when
Secure Boot is enabled, as it can't be verified, and the DeviceTree
loader feature is advertised.
2026-10-03 20:23:57 -07:00
alex ef14e5bd98 feat(bls): parse the devicetree key 2026-10-03 20:23:57 -07:00
alex 5b345c82d1 feat(eficore): find the extended boot loader partition on the disk of a partition
Look for a filesystem on the same disk whose GPT partition type is the
XBOOTLDR type, using the partition info protocol. open_shared moves to
the handle module so the graphical menu and the discovery share it.
2026-10-03 20:23:57 -07:00
alex d41bcaa292 feat(bls): reboot when an entry with boot counting fails to start
Add the reboot-on-error loader.conf setting. With auto, which is the
default, the machine resets after a failed start only if a boot counter
try was just used up and there were tries left, so the entry eventually
runs out of tries and the next boot picks another one. yes always resets
and no never does.
2026-10-03 20:23:57 -07:00
alex 14bcfc2653 fix(bls): write boot counters systemd can read and match systemd on small details
Do not pad the boot counter digits or cap the tries done counter, as
systemd-bless-boot reads a counter with a leading zero as octal and
systemd-boot never writes one. A profile only selects a profile when it
is a number above zero, an unterminated bracket in a pattern matches
nothing, and files that start with auto- are ignored in any case and for
unified kernel images too. The saved entry is compared ignoring case, and
entry settings are only expanded for sources that are looked at.
2026-10-03 19:41:39 -07:00
alex 8bb5f1132d fix(menu): let a key press show a hidden menu
A menu with a zero timeout or menu-hidden now waits 100 ms for a key press
before booting the default entry, which is how the boot loader interface
lets a user interrupt a hidden menu. menu-disabled still skips the wait.
2026-10-03 19:41:39 -07:00
alex 590eeead5e fix(boot): match and publish BLS entry ids like systemd-boot
Entries now have an id, the file name without the boot counter and with
its .conf or .efi extension in lower case, which is what systemd-boot
publishes in LoaderEntries and LoaderEntrySelected and what bootctl
expects. LoaderEntryDefault, LoaderEntryOneShot and loader.conf match by
id, ignoring case, with *, ? and [a-z], and no longer match titles or
numbers. A type 1 and a type 2 entry with the same name no longer clash.

@saved now works from LoaderEntryDefault as well, stops saving when it
is not asked for, and removes a stale saved entry. LoaderEntryPreferred
and the loader.conf preferred key select an entry that skips bad boot
counter entries. An entry with no tries left that is booted by hand is
counted too, and a read-only entry file is left alone.

Type 1 entries can name an architecture, which hides them on another
architecture, and a uki with an optional profile. Files that start with
auto- are ignored. loader.conf is read lossily, and a failed measurement
no longer stops the boot.
2026-10-03 19:41:39 -07:00
alex a26a5f1639 feat(eficore): advertise the sort key and preferred entry features 2026-10-03 19:41:39 -07:00
alex 6ea9fa9dbf feat(bls): parse the architecture, uki and profile keys 2026-10-03 19:41:39 -07:00
alex 9d123d6a13 feat(bls): keep boot counter digit widths and sort ties by tries 2026-10-03 19:41:39 -07:00
alex cd49b27ccb feat(bls): read the loader.conf preferred key and strip quotes 2026-10-03 19:41:39 -07:00
alex 4794de9db5 feat(parsing): match entry ids the way systemd-boot does 2026-10-03 19:41:39 -07:00
alex f4221131f1 feat(bls): boot unified kernel images (Type #2 entries) (#111)
* feat(bls): read PE sections without loading the whole image

* feat(bls): produce entries from unified kernel images

* feat(bls): boot unified kernel images as type 2 entries

Scan the EFI/Linux directory, or the new uki-path option of the BLS
generator, for .efi files and turn each one into an entry using the
metadata in its PE sections. Autoconfigure now also detects a disk that
only has unified kernel images. An image with the id of a type 1 entry is
skipped, and boot counting works on the .efi file name.

* fix(boot): rename boot counter files in place instead of copying them

* fix(bls): skip hidden files and keep unified kernel images when the type 1 scan fails

Check file name suffixes without slicing by byte offset, drop the PE
section bytes once an image has been parsed, and only fail the generator
on a type 1 error when unified kernel images are disabled.
2026-10-03 19:09:17 -07:00
dependabot[bot] 98e8c9ad44 chore(deps): bump astral-sh/setup-uv in the actions-updates group
Bumps the actions-updates group with 1 update: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv).


Updates `astral-sh/setup-uv` from 10.1.0 to 10.2.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/bec219d24cd3e171d82865faccec33120bb574f4...c18668ad3cf93ea998bef934396af7bb5c839dc7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-03 18:59:43 -07:00
alex 1e3b0d3081 fix(hack): use Homebrew's firmware for accelerated aarch64 VMs
The firmware in the dev image hangs before it starts Sprout when run with
hvf on Apple Silicon. QEMU_FIRMWARE overrides which firmware is used.
2026-10-03 18:59:20 -07:00
alex 27d2a5c56e feat(menu): inset the graphical menu in a panel with the logo above it
Entries now sit in a centered panel instead of spanning the screen, and only
the selected entry gets a pill. The logo and name are in a header above the
panel and always show. Long titles end in an ellipsis, and a scrollbar and
position show when the list doesn't fit. Text scale is capped so 1440p
displays aren't dominated by the titles.
2026-10-03 18:05:12 -07:00
alex 1bebcd2ecc fix(menu): draw the graphical menu to every display
The first graphics output may not be a display anyone is looking at. Open
each output that has a device path, and skip the console splitter's output
unless it is the only one. The cursor is shown at the same relative place on
each display, and hovering over an entry on any of them selects it.
2026-10-03 18:05:12 -07:00
alex 795e310ae7 fix(menu): open the graphics output shared to keep the text console connected 2026-10-03 18:05:12 -07:00
alex e12e5dd7cf fix(menu): open the pointers shared so the graphical menu doesn't hang
An exclusive open makes the firmware disconnect the drivers that use the
protocol, and on some firmware that call never returns. Prefer the pointer
on the console input handle, which belongs to the console splitter.
2026-10-03 18:05:12 -07:00
alex de2df4a183 fix(eficore): don't panic when filling a rectangle past the right edge
The slice start was after its end when the rectangle began beyond the
framebuffer width, which happens when the cursor is at the right edge.
2026-10-03 18:05:12 -07:00
alex 7d913b3ab4 fix(boot): load loader.conf before one-shot variables and normalize the boot count path 2026-10-03 17:39:38 -07:00
alex 2c18f229c0 fix(boot): skip bad entries when picking the default source, don't save forced boots for @saved 2026-10-03 17:39:38 -07:00
alex eac6215a76 feat(boot): use loader.conf, @saved and the boot count path
Read \loader\loader.conf from the partition Sprout was loaded from and
use its default and timeout as the lowest priority source. Sprout now
picks the timeout in this order: LoaderConfigTimeoutOneShot,
--menu-timeout, the configuration, LoaderConfigTimeout, loader.conf. The
default entry order is the configuration, LoaderEntryDefault, loader.conf,
and a source that matches no entry falls through to the next one.

To tell an explicit menu-timeout from the built-in default, the
configuration option is now optional. Existing files are unaffected.

A loader.conf default of @saved selects the entry in
LoaderEntryLastBooted and saves the booted entry there on every boot.

After consuming a boot counter try, publish the renamed entry file in
LoaderBootCountPath and advertise the boot counting and saved entry
features.
2026-10-03 17:39:38 -07:00
alex 5c517473e3 feat(bls): parse loader.conf 2026-10-03 17:39:38 -07:00
alex 1207f1d4e9 feat(bls): boot counting (#107)
* feat(bls): parse and render boot counters

* feat(bls): sort entries with no boot tries left last

* feat(boot): strip boot counters from bls entry ids

* feat(boot): consume bls boot counter tries before booting

* fix(boot): never default to a bad boot counter entry while a good one exists
2026-10-03 17:19:27 -07:00
alex 5e8fe7c783 feat(menu): add a graphical boot menu (#106)
* feat(menu): add a graphical boot menu

Adds the graphical menu style, selected with menu-style = "graphical" or
--menu-style=graphical. It draws to the graphics output with the existing
framebuffer, using a built-in 8x8 font, and takes input from the keyboard
and from relative or absolute pointers. The Sprout logo is drawn in the
bottom right corner. If there is no graphics output, the simple menu is
used, which still falls back to the basic menu.

The logo is converted from assets/logo-small.png to raw pixels by the build
crate, which adds the png crate as a build-time dependency only.

The default menu style is still simple. Pointer input is untested, as
OVMF has no mouse driver.

* fix(build): only build the logo conversion on the host

CI builds the whole workspace for the UEFI target, which also builds the
build crate for UEFI. The png dependency pulls in simd-adler32, which
crashes LLVM on that target. The logo conversion only runs in build
scripts on the host, so png and the conversion are now left out when
targeting UEFI.
2026-10-03 17:01:48 -07:00
alex 96b6c6f86a feat(sprout): generator variants (#105)
Generators can now declare variant axes. Every generated entry is
multiplied by each combination of choices, one per axis. A choice sets
the axis name to the choice name and inserts its own values, and the
entry name is suffixed with the choice names.

Generators can also declare exclude rules, which remove generated
entries whose values match every glob pattern in a rule. This covers
unwanted variant combinations and entries like BLS rescue kernels.

Entry matching for default-entry and the bootloader interface now
accepts `*` anywhere in the pattern instead of only at the end, so
generated entry names can be matched regardless of the kernel version
in the middle of the name.

The BLS generator gains a pin-names option (default true, the existing
behavior). Two BLS generators reading the same directory previously
produced entries with identical names; disabling pin-names prefixes
them with the generator name instead. Sprout now warns when more than
one entry has the same name.

* docs(config): note that variants suffix pinned BLS entry names

Pinned BLS entry names match the BLS entry ids, but variants still
append their choice names, so those entries need a pattern like
<id>-* to be selected.
2026-10-03 15:16:05 -07:00
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
alex 5afbd15fd0 sprout: version 0.0.29 (#99) 2026-09-26 19:07:20 -07:00
alex 2da1cea25c fix(autoconfigure): skip filesystems that can't be opened
If the device path or filesystem protocol of any detected filesystem
could not be opened, autoconfiguration failed entirely and boot was
aborted. Log a warning and skip that filesystem instead, so the other
filesystems are still autoconfigured.
2026-09-26 18:58:54 -07:00
alex 84bd84b7d2 fix(phases): run the late phase after the entry is chosen
The late phase is documented to run after the entry is chosen but
before its actions are executed, but it ran before the boot menu was
shown. Run it after the entry is selected and reported to the
bootloader interface, right before executing the entry's actions.
2026-09-26 18:58:54 -07:00
alex db0edb1b6e fix(bls): sort entries with a version before entries without
compare_versions_optional treated a missing version as newer than any
version, so among entries with equal sort-key and machine-id, an entry
without a version sorted first. Treat a missing version as the oldest,
matching systemd's strverscmp_improved.
2026-09-26 18:58:54 -07:00
alex 282dc5369a fix(bls): sort entries with a sort-key before entries without
Sort keys were compared as Options, so an entry without a sort-key
sorted before one with a sort-key, and machine-id and version were
compared even when neither entry had a sort-key. Per the Boot Loader
Specification and systemd-boot, an entry with a sort-key sorts first,
and machine-id and version are only used when both entries have one.
2026-09-26 18:58:54 -07:00
alex 44c8ab28af fix(bls): compare version numbers of any length
Numeric segments were parsed into a saturating u64, so any two numbers
above u64::MAX compared equal. Compare the digits instead, by length
and then digit by digit, as systemd does.
2026-09-26 18:58:54 -07:00
alex 29e405f11f fix(bls): sort numeric version segments newer than letters
When only one version had a digit at the current position, both sides
were parsed as numbers and the letter side read as 0. "0" then compared
equal to "a" and the result depended on what followed. Per the
specification, the side with digits is newer.
2026-09-26 18:58:54 -07:00
alex 9d6d88c5e0 fix(bls): sort pre-release versions before releases
The '~' check ran after the end-of-string check, so "1~rc1" compared as
newer than "1". Per the UAPI version format specification, '~' must be
handled first so a pre-release sorts older than the release it
precedes. An empty string is kept as the oldest version, matching
systemd's strverscmp_improved.
2026-09-26 18:58:54 -07:00
alex dd35c2153b fix(bls): skip unreadable or invalid BLS entries
A single BLS entry that could not be read, was not valid UTF-8, or
failed to parse aborted entry generation and therefore the whole boot,
contrary to the generator's documented best-effort behavior. Log a
warning and skip the entry instead.
2026-09-26 18:58:54 -07:00
alex dbc292e369 fix(bls): allow BLS directories without an entries directory
Autoconfigure detects BLS when either \loader\loader.conf or
\loader\entries exists, but the BLS generator required the entries
directory and failed the boot without it. This broke ESPs that only
have a loader.conf, such as UKI-only systemd-boot setups. Generate no
entries when the directory is missing.
2026-09-26 18:58:54 -07:00
alex 6f84a1014a fix(chainload): skip initrd paths without a file component
Autoconfigured BLS actions use "$root\\$initrd" as the initrd path.
For BLS entries without an initrd line this stamps to the root of the
filesystem, and Sprout tried to read that directory as the initrd,
failing the boot. Treat a path that resolves to no file as no initrd.
2026-09-26 18:58:54 -07:00
alex 9967a7ab80 fix(chainload): release LoadedImage before starting the image
The chainload action held the child image's LoadedImage protocol open
exclusively while calling start_image. Any image that opens its own
LoadedImage protocol exclusively, such as another uefi-rs application
or Sprout itself, then failed with ACCESS_DENIED. Scope the protocol to
setting the load options.
2026-09-26 18:58:54 -07:00
alex bcfb7dc680 fix(shim): take security arch2 boot_policy as a UEFI Boolean
The firmware passes BootPolicy as a UEFI BOOLEAN (a u8). Declaring it
as a Rust bool in the extern signature is undefined behavior for any
value other than 0 or 1. Use uefi_raw::Boolean instead.
2026-09-26 18:58:54 -07:00
alex bcc78fd4a3 fix(shim): don't overwrite original handlers when already hooked
If the security hook was installed while already installed, the
protocols contained Sprout's own handlers, which were then saved as the
"original" handlers. A verification failure would then call back into
the hook forever. Keep the existing saved state in that case.
2026-09-26 18:58:54 -07:00
alex ad37a2bcf3 fix(variables): accept UTF-16 values without a NUL terminator
EFI string variables written without a trailing NUL, or with extra NUL
padding, failed to decode and were silently treated as unset. Decode up
to the first NUL and terminate the string ourselves, as systemd-boot
does.

Because empty values now decode to an empty string rather than failing,
treat empty LoaderEntryDefault and LoaderEntryOneShot values as unset so
an empty one-shot entry doesn't fail to match any entry.
2026-09-26 18:58:54 -07:00
alex df5d8f5c39 fix(bootloader-interface): ignore invalid timeout values
A LoaderConfigTimeout or LoaderConfigTimeoutOneShot value that is not a
number or a known keyword made Sprout abort boot. Log a warning and
treat the variable as unset instead, matching systemd-boot.
2026-09-26 18:58:54 -07:00
alex 48e3bc48a9 fix(loader): install the security hook right before LoadImage
The hook was installed before retaining the shim protocol and reading
the image, so an error in either step returned without uninstalling
it. Install it immediately before LoadImage instead.
2026-09-26 18:58:54 -07:00
alex 5c2724fd22 fix(env): filter all ASCII control characters from leading args
The check for unprintable leading arguments added by firmware used
c < 0x1f, which missed 0x1f itself. Use is_ascii_control() instead.
2026-09-26 18:58:54 -07:00
alex f7e7a9122b fix(menu): ignore key events without an available key
Some firmware signals the key event without a key being available.
This previously aborted boot with "no key was pressed"; treat it as a
no-op and keep waiting instead.
2026-09-26 18:58:54 -07:00
alex 1f5bc07c22 fix(autoconfigure): match arm64 Image kernels
File names are lowercased before matching kernel prefixes, but the
arm64 prefix was "Image", so it could never match and arm64 kernels
were not autoconfigured. Use a lowercase "image" prefix.
2026-09-26 18:58:54 -07:00
dependabot[bot] 724180618b chore(deps): bump the actions-updates group across 1 directory with 9 updates (#93)
Bumps the actions-updates group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.17.0` | `2.21.1` |
| [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `7.0.1` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `8.0.0` | `10.1.0` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.35.1` | `4.38.1` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.35.1` | `4.38.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.35.1` | `4.38.1` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.0` | `7.0.1` |
| [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) | `4.1.0` | `4.2.2` |
| [actions/create-github-app-token](https://github.com/actions/create-github-app-token) | `3.0.0` | `3.2.0` |



Updates `step-security/harden-runner` from 2.17.0 to 2.21.1
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/f808768d1510423e83855289c910610ca9b43176...e14015d583714f6e62063499dc959a02595150a1)

Updates `actions/checkout` from 6.0.2 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...3d3c42e5aac5ba805825da76410c181273ba90b1)

Updates `astral-sh/setup-uv` from 8.0.0 to 10.1.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/cec208311dfd045dd5311c1add060b2062131d57...bec219d24cd3e171d82865faccec33120bb574f4)

Updates `github/codeql-action/upload-sarif` from 4.35.1 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/c10b8064de6f491fea524254123dbe5e09572f13...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `github/codeql-action/init` from 4.35.1 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/c10b8064de6f491fea524254123dbe5e09572f13...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `github/codeql-action/analyze` from 4.35.1 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/c10b8064de6f491fea524254123dbe5e09572f13...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `actions/upload-artifact` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/bbbca2ddaa5d8feaa63e36b76fdaad77386f024f...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)

Updates `actions/attest-build-provenance` from 4.1.0 to 4.2.2
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32...4d101475d8b20a2381f78447822ac1eab6504dd8)

Updates `actions/create-github-app-token` from 3.0.0 to 3.2.0
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/f8d387b68d61c58ab83c6c016672934102569859...bcd2ba49218906704ab6c1aa796996da409d3eb1)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-updates
- dependency-name: actions/create-github-app-token
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: astral-sh/setup-uv
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: github/codeql-action/init
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: step-security/harden-runner
  dependency-version: 2.19.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-26 18:37:06 -07:00
Ben Leggett 8b39f703c6 fix(ci): scope token (#94) 2026-09-26 18:24:44 -07:00
alex 29cdc75776 chore(deps): bump rust to 1.98.1, bump all images, and dependencies (#97) 2026-09-25 11:28:32 -07:00
alex 0e4452b625 chore(upgrade): upgrade to rust v1.97.1, bump deps and migrate, bump images (#95) 2026-08-02 19:02:09 -07:00
alex aafda6e3da chore(deps): upgrade spin and shlex (#86) 2026-05-19 11:51:37 -05:00
Luca Di Maio 863aa25971 Merge pull request #85 from edera-dev/89luca89/tpm_work_ws2
sprout: measure xen and dom0 payloads and cmdlines into PCRs 9/11/12
2026-05-18 13:24:50 +02:00
Luca Di Maio 2c1c380ab5 sprout: measure xen and dom0 payloads and cmdlines into PCRs 9/11/12
Extend PCR 9 with the initrd, PCR 11 with the dom0 kernel,
and PCR 12 with the Xen options and dom0 cmdline.

Signed-off-by: Luca Di Maio <[email protected]>
2026-05-18 12:43:41 +02:00
dependabot[bot] 076be95306 chore(deps): bump the actions-updates group across 1 directory with 3 updates (#81)
Bumps the actions-updates group with 3 updates in the / directory: [step-security/harden-runner](https://github.com/step-security/harden-runner), [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [github/codeql-action](https://github.com/github/codeql-action).


Updates `step-security/harden-runner` from 2.16.0 to 2.16.1
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594...f808768d1510423e83855289c910610ca9b43176)

Updates `astral-sh/setup-uv` from 7.2.0 to 8.0.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/61cb8a9741eeb8a550a1b8544337180c0fc8476b...cec208311dfd045dd5311c1add060b2062131d57)

Updates `github/codeql-action` from 4.31.2 to 4.35.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/0499de31b99561a6d14a36a5f662c2a54f91beee...c10b8064de6f491fea524254123dbe5e09572f13)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.16.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: astral-sh/setup-uv
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-updates
- dependency-name: github/codeql-action
  dependency-version: 4.35.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 14:45:25 -07:00
alex 6f9c4a2852 chore(deps): upgrade rust and dependencies (#82) 2026-04-16 21:43:36 +00:00
Ben Leggett d75fce041c Merge pull request #75 from edera-dev/bleggett/bls-unit-tests
Split out unit testable code
2026-03-30 18:08:53 -04:00
dependabot[bot] f9391261d5 chore(deps): bump the actions-updates group across 1 directory with 4 updates (#74)
Bumps the actions-updates group with 4 updates in the / directory: [step-security/harden-runner](https://github.com/step-security/harden-runner), [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) and [actions/create-github-app-token](https://github.com/actions/create-github-app-token).


Updates `step-security/harden-runner` from 2.14.1 to 2.16.0
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/e3f713f2d8f53843e71c69a996d56f51aa9adfb9...fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594)

Updates `actions/upload-artifact` from 6.0.0 to 7.0.0
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f)

Updates `actions/attest-build-provenance` from 3.2.0 to 4.1.0
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/96278af6caaf10aea03fd8d33a09a777ca52d62f...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32)

Updates `actions/create-github-app-token` from 3.0.0.pre.beta.2 to 3
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Commits](https://github.com/actions/create-github-app-token/compare/bf559f85448f9380bcfa2899dbdc01eb5b37be3a...f8d387b68d61c58ab83c6c016672934102569859)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-updates
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-updates
- dependency-name: actions/create-github-app-token
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 20:23:45 +01:00
Benjamin Leggett c44b429553 fmt 2026-03-25 16:13:46 -04:00
Benjamin Leggett c1115af841 Skip the UEFI crates during cargo test 2026-03-25 16:07:21 -04:00
Benjamin Leggett b53d21cea5 Split out parsing stuff as well, and test it 2026-03-25 16:01:28 -04:00
Benjamin Leggett 133476a0df Split out some BLS stuff into a separate testable crate without UEFI types 2026-03-25 15:31:47 -04:00
alex c70d1e4d80 sprout: version 0.0.28 (#64) 2026-02-09 12:34:08 -08:00
Ariadne Conill 1f6fc237c6 Merge pull request #66 from edera-dev/chore/upgrade
chore(upgrade): bump all dependencies, rust, and images
2026-02-09 09:52:12 -08:00
alex c86215f196 chore(upgrade): bump all dependencies, rust, and images 2026-02-09 09:38:57 -08:00
dependabot[bot] 425ce8ede4 chore(deps): bump actions/attest-build-provenance (#65)
Bumps the actions-updates group with 1 update: [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance).


Updates `actions/attest-build-provenance` from 3.1.0 to 3.2.0
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8...96278af6caaf10aea03fd8d33a09a777ca52d62f)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 08:55:39 -08:00
dependabot[bot] 656fde2044 chore(deps): bump step-security/harden-runner (#63)
Bumps the actions-updates group with 1 update: [step-security/harden-runner](https://github.com/step-security/harden-runner).


Updates `step-security/harden-runner` from 2.14.0 to 2.14.1
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/20cf305ff2072d973412fa9b1e3a4f227bda3c76...e3f713f2d8f53843e71c69a996d56f51aa9adfb9)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 15:20:17 -05:00
Ariadne Conill 5740c14e31 Merge pull request #62 from edera-dev/chore/code-cleanup-stamp-iter
chore(sprout): implement iterator stamping to cleanup code
2026-02-02 19:12:14 +01:00
alex c295de4951 chore(sprout): implement iterator stamping to cleanup code 2026-01-26 20:13:52 -08:00
dependabot[bot] 95e0360721 chore(deps): bump toml in the cargo-updates group (#59)
Bumps the cargo-updates group with 1 update: [toml](https://github.com/toml-rs/toml).


Updates `toml` from 0.9.10+spec-1.1.0 to 0.9.11+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.10...toml-v0.9.11)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.11+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 08:54:07 -08:00
dependabot[bot] 1370e1b39b chore(deps): bump the actions-updates group across 1 directory with 3 updates (#61)
Bumps the actions-updates group with 3 updates in the / directory: [actions/checkout](https://github.com/actions/checkout), [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance).


Updates `actions/checkout` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

Updates `astral-sh/setup-uv` from 7.1.6 to 7.2.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/681c641aba71e4a1c380be3ab5e12ad51f415867...61cb8a9741eeb8a550a1b8544337180c0fc8476b)

Updates `actions/attest-build-provenance` from 3.0.0 to 3.1.0
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/977bb373ede98d70efdf65b84cb5f73e068dcc2a...00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: actions/attest-build-provenance
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 08:53:40 -08:00
alex b60c345958 sprout: version 0.0.27 (#57) 2025-12-23 22:51:26 -08:00
dependabot[bot] 45270d3bf3 chore(deps): bump the actions-updates group with 3 updates (#56)
Bumps the actions-updates group with 3 updates: [step-security/harden-runner](https://github.com/step-security/harden-runner), [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/upload-artifact](https://github.com/actions/upload-artifact).


Updates `step-security/harden-runner` from 2.13.2 to 2.14.0
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/95d9a5deda9de15063e7595e9719c11c38c90ae2...20cf305ff2072d973412fa9b1e3a4f227bda3c76)

Updates `astral-sh/setup-uv` from 7.1.2 to 7.1.6
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41...681c641aba71e4a1c380be3ab5e12ad51f415867)

Updates `actions/upload-artifact` from 5.0.0 to 6.0.0
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/330a01c490aca151604b8cf639adc76d48f6c5d4...b7c566a772e6b6bfb58ed0dc250532a479d7789f)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-updates
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.1.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
- dependency-name: actions/upload-artifact
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-23 18:54:25 -08:00
alex 6a57c72869 feat(boot): introduce sort keys, which are sorted in reverse order, to make boot order more configurable (#55) 2025-12-21 14:11:26 -08:00
alex d50f22a386 chore(docs): fix all rustdoc warnings (#54) 2025-12-21 14:11:10 -08:00
alex a8a3774c35 chore(upgrade): upgrade dependencies, rust => 1.92.0, dev kernel => 6.18.2, alpine => v3.23 (#53) 2025-12-20 23:00:51 -08:00
alex 81c8217ee0 fix(boot): on boot handoff, clear the screen using clear rather than reset (#52)
On some Dell firmware, the reset operation doesn't actually clear the screen.
This change uses clear instead of reset to ensure the screen is properly cleared.
2025-12-20 22:29:44 -08:00
alex 17e729d068 fix(eficore): unregister media loader on drop to prevent more opportunities for leaks (#50) 2025-12-20 22:01:04 -08:00
alex 2da457ee7c feat(boot): clear screen before boot handoff, if --retain-boot-console is not specified on the command-line (#51) 2025-12-20 21:45:35 -08:00
dependabot[bot] fafabe234e chore(deps): bump log from 0.4.28 to 0.4.29 in the cargo-updates group (#48)
Bumps the cargo-updates group with 1 update: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.28 to 0.4.29
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.28...0.4.29)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-20 19:55:23 -08:00
dependabot[bot] c2cafe6c20 chore(deps): bump rust in the docker-updates group (#46)
Bumps the docker-updates group with 1 update: rust.


Updates `rust` from `fbcca3e` to `8efbfb7`

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.91.1-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docker-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 10:53:01 -08:00
dependabot[bot] bf28558a83 chore(deps): bump actions/checkout in the actions-updates group (#49)
Bumps the actions-updates group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5.0.0 to 6.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...8e8c483db84b4bee98b60c0593521ed34d9990e8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-18 11:39:56 -08:00
Ariadne Conill 0b75e547f7 Merge pull request #43 from edera-dev/chore/dev-alpine-clean
chore(dev): make alpine boot fully clean with kernel module infra
2025-11-27 20:33:10 -08:00
alex 5605056c82 chore(dev): make alpine boot fully clean with kernel module infra 2025-11-26 23:05:01 -08:00
alex d4fcba18c0 Merge pull request #39 from edera-dev/dependabot/github_actions/actions-updates-c5043b94ad
chore(deps): bump step-security/harden-runner from 2.13.1 to 2.13.2 in the actions-updates group
2025-11-25 21:35:22 -08:00
a dinosaur 5dcd763db9 chore(options):use jaarg alloc-less api, removing map middleman (#41) 2025-11-25 21:34:18 -08:00
alex 4f8abadb3a Merge pull request #42 from edera-dev/fix/pr-workflow-attest
fix(workflows): disable artifact publish attestation for pull requests
2025-11-25 20:43:24 -08:00
alex 57e90a4623 fix(workflows): disable artifact publish attestation for pull requests 2025-11-25 20:38:49 -08:00
dependabot[bot] 136b899844 chore(deps): bump step-security/harden-runner
Bumps the actions-updates group with 1 update: [step-security/harden-runner](https://github.com/step-security/harden-runner).


Updates `step-security/harden-runner` from 2.13.1 to 2.13.2
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](https://github.com/step-security/harden-runner/compare/f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a...95d9a5deda9de15063e7595e9719c11c38c90ae2)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-updates
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-11-24 11:01:47 +00:00
alex 0dbd011648 Merge pull request #40 from edera-dev/chore/release-v0.0.26
chore(release): sprout: version 0.0.26
2025-11-17 10:18:11 -08:00
alex 65c392944b chore(release): sprout: version 0.0.26 2025-11-17 10:14:58 -08:00
Ariadne Conill 2683229bd3 Merge pull request #35 from edera-dev/chore/dev-improve-alpine
chore(dev): autologin to alpine and writable rootfs
2025-11-17 10:01:17 -08:00
alex a7a9554875 Merge pull request #38 from edera-dev/feat/partial-match-default
feat(entries): support '*' suffix as a partial match to an entry
2025-11-15 21:25:59 -08:00
alex 5ad617c54f feat(entries): support '*' suffix as a partial match to an entry 2025-11-15 20:36:31 -08:00
alex 0aa7a46808 Merge pull request #27 from edera-dev/feat/jaarg
feat(options): replace options parser with jaarg
2025-11-15 18:02:46 -08:00
alex 8711c54074 feat(boot): utilize jaarg for options parsing 2025-11-15 17:31:46 -08:00
Ariadne Conill c21c140039 Merge pull request #37 from edera-dev/fix/linux-auto-detect-aarch64-images
fix(autoconfigure/linux): detect Image as a kernel, which openSUSE uses
2025-11-15 14:50:18 -08:00
alex c053f62b88 fix(autoconfigure/linux): detect Image as a kernel, which openSUSE uses 2025-11-15 13:41:07 -08:00
alex e0bd703511 chore(dev): autologin to alpine and writable rootfs 2025-11-15 13:37:50 -08:00
124 changed files with 10592 additions and 1891 deletions

No files matched your search

@@ -0,0 +1,83 @@
---
name: update-config-docs
description: Bring docs/config.md back in line with the code. Use after changing anything in crates/config, the command line options, loader.conf handling, strict mode, or autoconfiguration, or when asked to check that docs/config.md is accurate.
---
# Update docs/config.md
`docs/config.md` is the reference for `sprout.toml`, the command line options, BLS support and strict mode.
The README only links to it, so a setting that is missing from docs/config.md is undocumented.
## 1. Find what changed
Start from the diff, or from the whole config surface if there is none:
```bash
git diff main -- crates/config crates/boot/src/options.rs crates/boot/src/config crates/boot/src/autoconfigure crates/bls
```
The sources of truth, and the section of docs/config.md each one feeds:
| Source | Section |
|-----------------------------------------------------------|-------------------------------------------|
| `crates/config/src/lib.rs` | file at a glance, `options` |
| `crates/config/src/drivers.rs` | `drivers` |
| `crates/config/src/extractors*` | `extractors` |
| `crates/config/src/actions*` | `actions` |
| `crates/config/src/entries.rs` | `entries` |
| `crates/config/src/generators*` | `generators`, `variants`, `exclude` |
| `crates/config/src/phases.rs` | `phases` |
| `crates/boot/src/options.rs` | command line options |
| `crates/boot/src/main.rs` | boot order, defaults, timeout and default entry order, strict mode |
| `crates/boot/src/autoconfigure*` | autoconfiguration |
| `crates/boot/src/generators/bls.rs`, `crates/bls` | BLS values, boot counting, `loader.conf` |
## 2. Check each key
For every struct field in `crates/config`, docs/config.md needs its TOML name (look at `#[serde(rename)]`, as
most multi-word keys use dashes), its type, its default, and what it does. Read the code that uses it in
`crates/boot`, not only the doc comment, because the behavior is what the reader depends on.
Look for the usual drift:
- a field added, renamed or removed
- a default that changed (`#[serde(default = ...)]`, the `Default` impls, constants such as
`DEFAULT_MENU_TIMEOUT_SECONDS`)
- a new BLS value set with `context.set`, or a new option in `SproutOptions`
- a new row to add to, or remove from, the strict mode table
- a changed order: the default entry sources, the menu timeout sources, or the phases
- `LATEST_VERSION` changing, which the intro and `version` text must follow
Don't document what you can't confirm in the code. If a behavior is unclear, read the function that
implements it.
## 3. Write the change
- Keep each section's shape: a short explanation, a TOML example, then a table of keys.
- Examples must parse. Check that every key in one exists and is spelled the way serde expects.
- Update the table of contents if you add or rename a heading.
- Keep README.md short. It gets the feature list and the minimal examples, and docs/config.md gets everything else.
- Don't duplicate the strict mode table or the default entry order anywhere else.
## 4. Write like a person
Plain sentences, said once. Skip these:
- filler like "robust", "seamless", "powerful", "leverage", "ensure", "note that", "it's important to"
- groups of three added for rhythm, and a summary sentence at the end of every section
- bullet lists for what a sentence says better, and bold used as decoration
- em dashes (use a comma, colon or parentheses)
Say what a setting does and what happens when it's unset or wrong. Name real things (`bootctl`, `PCR 12`,
`\EFI\Linux`) instead of describing them.
## 5. Verify
```bash
cargo build --target x86_64-unknown-uefi
cargo build --target aarch64-unknown-uefi
cargo fmt --all --check
```
Then re-read the diff of docs/config.md against the code once more for any key whose name or default you
touched. If the change was only to docs, skip the builds.
-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:
+7 -6
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:
@@ -25,17 +26,17 @@ jobs:
actions: read # Needed to analyze action metadata.
steps:
- name: harden runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: setup uv
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
- name: zizmor
run: uvx zizmor --pedantic --format sarif . > results.sarif
@@ -43,7 +44,7 @@ jobs:
GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: upload
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: results.sarif
category: zizmor
+9 -8
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:
@@ -21,12 +22,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: harden runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -51,12 +52,12 @@ jobs:
name: 'build ${{ matrix.arch }}'
steps:
- name: harden runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -80,12 +81,12 @@ jobs:
name: 'clippy ${{ matrix.arch }}'
steps:
- name: harden runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+7 -6
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:
@@ -37,23 +38,23 @@ jobs:
build-mode: none
steps:
- name: harden runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: initialize codeql
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yaml
- name: perform codeql analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{matrix.language}}"
+27 -10
View File
@@ -12,25 +12,26 @@ 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -43,13 +44,29 @@ jobs:
- name: 'upload artifacts'
id: upload
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
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@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: audit
- name: 'attest artifacts'
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: artifacts.zip
subject-digest: "sha256:${{ steps.upload.outputs.artifact-digest }}"
subject-digest: "sha256:${{ needs.artifacts.outputs.artifact-digest }}"
+11 -5
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:
@@ -25,13 +26,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: harden runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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'
@@ -42,16 +45,19 @@ jobs:
run: ./hack/assemble.sh
- name: 'attest release artifacts'
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: target/assemble/*
- name: 'generate cultivator token'
uses: actions/create-github-app-token@bf559f85448f9380bcfa2899dbdc01eb5b37be3a # v3.0.0-beta.2
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
id: generate-token
with:
app-id: "${{ secrets.EDERA_CULTIVATION_APP_ID }}"
private-key: "${{ secrets.EDERA_CULTIVATION_APP_PRIVATE_KEY }}"
# Scope to what uploading assets and `gh release edit` need: write to
# release contents.
permission-contents: write
if: ${{ github.event.inputs.release-tag != '' }}
- name: 'upload release artifacts'
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+58
View File
@@ -0,0 +1,58 @@
# Sprout
Sprout is a programmable UEFI bootloader written in Rust, for `x86_64` and `aarch64`. It is a `no_std` workspace
built for the `*-unknown-uefi` targets, so it cannot be run or tested like a normal binary.
See [README.md](README.md) for features and [docs/config.md](docs/config.md) for the configuration format.
After changing configuration, run the `update-config-docs` skill to keep docs/config.md accurate.
## Layout
- `crates/boot`: the bootloader entrypoint (`edera-sprout-boot`).
- `crates/bls`: Boot Loader Specification parsing and sorting.
- `crates/build`: build logic.
- `crates/config`: serialization structures for the Sprout configuration file.
- `crates/eficore`: core EFI helpers.
- `crates/parsing`: value stamping and other parsing helpers.
- `hack/`: build and dev scripts; `hack/dev` boots QEMU, `hack/dev/run` drives it headless.
- `docs/setup`: setup guides for signed and unsigned installs.
The crate list and the scripts are described in [DEVELOPMENT.md](DEVELOPMENT.md).
## Building and checking
The toolchain and both UEFI targets are pinned in `rust-toolchain.toml`. Always pass a UEFI target, as the
host target will not build.
```bash
cargo build --target x86_64-unknown-uefi
cargo clippy --target x86_64-unknown-uefi
cargo fmt --all
./hack/assemble.sh
```
CI runs `cargo fmt --check`, `cargo build` and `cargo clippy` for both targets, so run these for both
`x86_64` and `aarch64` before opening a PR. `./hack/autofix.sh` applies clippy and rustfmt fixes.
To try a change in a real environment, use `./hack/dev/boot.sh [arch]`, or the scripts in `hack/dev/run`
(for example `smoke.sh`) when there is no terminal or window to use.
## Conventions
- Write code that matches the surrounding code, including naming, idioms and comment style.
- Dependencies are declared in the root `Cargo.toml` under `[workspace.dependencies]`.
- GitHub Actions are pinned to a commit SHA with the version in a comment.
## Commits and pull requests
- Do not add coding agent attribution anywhere. No `Co-Authored-By` trailers, no "Generated with" lines, in
commits or in PR descriptions.
- Commits use [Conventional Commits](https://www.conventionalcommits.org/), in the form `type(scope): subject`,
for example `fix(boot): count tries without using them`. Types in use are `feat`, `fix` and `docs`, and
scopes are usually a crate name such as `boot`, `bls` or `eficore`, or `ci`, `hack` and `readme`.
- PR titles are also conventional commits, because PRs are squashed. The only exception is a release PR,
which is titled `sprout: version x.y.z`.
- Keep PR descriptions short prose.
## Releasing
See [RELEASING.md](RELEASING.md). A release PR only bumps the version in `Cargo.toml` and `Cargo.lock`.
+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
+189 -86
View File
@@ -3,10 +3,16 @@
version = 4
[[package]]
name = "anyhow"
version = "1.0.100"
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bit_field"
@@ -16,64 +22,83 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "2.10.0"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"generic-array",
"hybrid-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cpufeatures"
version = "0.2.17"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"generic-array",
"typenum",
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "digest"
version = "0.10.7"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "edera-sprout-boot"
version = "0.0.25"
name = "edera-sprout-bls"
version = "0.0.31"
dependencies = [
"anyhow",
]
[[package]]
name = "edera-sprout-boot"
version = "0.0.31"
dependencies = [
"anyhow",
"edera-sprout-bls",
"edera-sprout-build",
"edera-sprout-config",
"edera-sprout-eficore",
"edera-sprout-parsing",
"hex",
"jaarg",
"log",
"serde_ignored",
"sha2",
"toml",
"uefi",
@@ -82,36 +107,56 @@ dependencies = [
[[package]]
name = "edera-sprout-build"
version = "0.0.25"
version = "0.0.31"
dependencies = [
"png",
]
[[package]]
name = "edera-sprout-config"
version = "0.0.25"
version = "0.0.31"
dependencies = [
"serde",
]
[[package]]
name = "edera-sprout-eficore"
version = "0.0.25"
version = "0.0.31"
dependencies = [
"anyhow",
"bitflags",
"log",
"shlex",
"spin",
"uefi",
"uefi-raw",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
name = "edera-sprout-parsing"
version = "0.0.31"
dependencies = [
"typenum",
"version_check",
"hex",
"sha2",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
@@ -121,10 +166,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "libc"
version = "0.2.177"
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "jaarg"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "534d589df1ef528a238f4bc4b1db081a1280f3aedf2695fd8971e9853a7fa4f6"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "lock_api"
@@ -137,33 +197,66 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79"
checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1"
checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0"
dependencies = [
"proc-macro2",
"quote",
@@ -172,9 +265,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.42"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -187,9 +280,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -197,18 +290,18 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
@@ -216,19 +309,29 @@ dependencies = [
]
[[package]]
name = "serde_spanned"
version = "1.0.3"
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -236,25 +339,25 @@ dependencies = [
]
[[package]]
name = "shlex"
version = "1.3.0"
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "spin"
version = "0.10.0"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10"
dependencies = [
"lock_api",
]
[[package]]
name = "syn"
version = "2.0.110"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
@@ -263,9 +366,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.8"
version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"serde_core",
"serde_spanned",
@@ -276,27 +379,27 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.7.3"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.4"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
]
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "ucs2"
@@ -309,9 +412,9 @@ dependencies = [
[[package]]
name = "uefi"
version = "0.36.1"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe9058b73ee2b6559524af9e33199c13b2485ddbf3ad1181b68051cdc50c17"
checksum = "368cef7d94514897e6dad4350945c7910a9f5bf9021191d1c58ee3ec09962697"
dependencies = [
"bitflags",
"cfg-if",
@@ -325,9 +428,9 @@ dependencies = [
[[package]]
name = "uefi-macros"
version = "0.19.0"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4687412b5ac74d245d5bfb1733ede50c31be19bf8a4b6a967a29b451bab49e67"
checksum = "643efa8b64be89780bc498b290fab04337c7c4d171cc82cce2e133a74616dcc4"
dependencies = [
"proc-macro2",
"quote",
@@ -336,9 +439,9 @@ dependencies = [
[[package]]
name = "uefi-raw"
version = "0.13.0"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f64fe59e11af447d12fd60a403c74106eb104309f34b4c6dbce6e927d97da9d"
checksum = "3636327a6d88a38c0f2567fb695ec7ba27b3483d1877730486605aa0866da201"
dependencies = [
"bitflags",
"uguid",
@@ -352,18 +455,18 @@ checksum = "0c8352f8c05e47892e7eaf13b34abd76a7f4aeaf817b716e88789381927f199c"
[[package]]
name = "unicode-ident"
version = "1.0.22"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "winnow"
version = "0.7.13"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+21 -13
View File
@@ -4,24 +4,27 @@ members = [
"crates/build",
"crates/config",
"crates/eficore",
"crates/bls",
"crates/parsing",
]
resolver = "3"
[workspace.package]
license = "Apache-2.0"
version = "0.0.25"
version = "0.0.31"
homepage = "https://sprout.edera.dev"
repository = "https://github.com/edera-dev/sprout"
edition = "2024"
[workspace.dependencies]
bitflags = "2.10.0"
log = "0.4.28"
spin = "0.10.0"
uefi-raw = "0.13.0"
bitflags = "2.13.2"
log = "0.4.34"
png = "0.18.1"
spin = "0.12.3"
uefi-raw = "0.17.0"
[workspace.dependencies.anyhow]
version = "1.0.100"
version = "1.0.104"
default-features = false
[workspace.dependencies.hex]
@@ -29,26 +32,31 @@ version = "0.4.3"
default-features = false
features = ["alloc"]
[workspace.dependencies.jaarg]
version = "0.2.2"
default-features = false
features = ["alloc"]
[workspace.dependencies.serde]
version = "1.0.228"
version = "1.0.229"
default-features = false
features = ["alloc", "derive"]
[workspace.dependencies.sha2]
version = "0.10.9"
[workspace.dependencies.serde_ignored]
version = "0.1.14"
default-features = false
[workspace.dependencies.shlex]
version = "1.3.0"
[workspace.dependencies.sha2]
version = "0.11.0"
default-features = false
[workspace.dependencies.toml]
version = "0.9.8"
version = "1.1.6"
default-features = false
features = ["serde", "parse"]
[workspace.dependencies.uefi]
version = "0.36.1"
version = "0.41.0"
default-features = false
features = ["alloc", "global_allocator", "panic_handler"]
+23 -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.
@@ -56,3 +58,23 @@ Build Sprout as OCI images using Docker, including a kernel, initramfs, xen, and
Boot Sprout's dev environment using QEMU for testing. This will let you test your changes in a real environment booting
Alpine Linux with an initramfs.
### ./hack/dev/run
Scripts to drive the dev environment without a terminal or a window, which suits automation and
coding agents. QEMU runs in the background with a QMP socket, and its consoles are written to
`target/run/<arch>/`. Only one run per architecture at a time, and the scripts need `python3` or `socat`.
- `start.sh [arch]`: Builds and starts QEMU headless. `SPROUT_CONFIG_NAME` defaults to `graphical`.
- `screenshot.sh [out.png]`: Saves a PNG of the display (QEMU 7.1 or newer) and prints the path.
- `key.sh <qcode>...`: Presses keys such as `down`, `up`, `ret` or `ctrl+alt+delete`.
- `wait-serial.sh <regex> [seconds]`: Waits for text on the firmware or Linux console.
- `qmp.sh <json>...`: Sends raw QMP commands.
- `status.sh` and `stop.sh`: Show the state of QEMU, and stop it.
- `smoke.sh [arch]`: Starts, drives the graphical menu, waits for the Linux shell and stops.
`boot.sh` supports this with `QEMU_HEADLESS`, `QEMU_QMP`, `QEMU_SERIAL_FILE`, `QEMU_CONSOLE_FILE` and `QEMU_EXTRA_ARGS`.
## Releasing
Maintainers cut releases as described in [RELEASING.md](RELEASING.md).
+1 -2
View File
@@ -1,8 +1,7 @@
# syntax=docker/dockerfile:1.7-labs
ARG RUST_PROFILE=release
ARG RUST_TARGET_SUBDIR=release
FROM --platform=$BUILDPLATFORM rust:1.91.1-alpine@sha256:fbcca3e30e26f79986809d5dbfcdbeaaf8d3f8a4475b7a19a973363b45c74d97 AS build
FROM --platform=$BUILDPLATFORM rust:1.99.1-alpine@sha256:557a94c82b485a14b569120e00867267108c734efe33f10cc31ca0cd5dd1eca9 AS build
RUN apk --no-cache add musl-dev busybox-static
ARG RUST_PROFILE
RUN adduser -S -s /bin/sh build
+24 -33
View File
@@ -58,6 +58,7 @@ We recommend running Sprout without Secure Boot for development, and with Secure
### Project Documentation
- [Configuration Reference]
- [Development Guide]
- [Contributing Guide]
- [Sprout License]
@@ -69,23 +70,31 @@ We recommend running Sprout without Secure Boot for development, and with Secure
### Current
- [x] Loadable driver support
- [x] Basic [Bootloader specification (BLS)](https://uapi-group.org/specifications/specs/boot_loader_specification/) support
- [x] [Bootloader specification (BLS)](https://uapi-group.org/specifications/specs/boot_loader_specification/) support:
Type #1 entries, Type #2 unified kernel images, and the extended boot loader partition
- [x] [UKI support](https://github.com/edera-dev/sprout/issues/6): including images with multiple profiles
- [x] Boot counting, so `systemd-bless-boot` can assess a boot
- [x] `loader.conf` support
- [x] Chainload support
- [x] Linux boot support via EFI stub
- [x] Windows boot support via chainload
- [x] Load Linux initrd from disk
- [x] Basic boot menu
- [x] Devicetree support
- [x] Basic, simple, and graphical boot menus
- [x] A strict mode that follows the BLS specification and systemd-boot exactly
- [x] Generators for BLS entries, lists, and matrices, with variants
- [x] BLS autoconfiguration support
- [x] [Secure Boot support](https://github.com/edera-dev/sprout/issues/20): beta
- [x] [Bootloader interface support](https://github.com/edera-dev/sprout/issues/21): beta
- [x] [BLS specification conformance](https://github.com/edera-dev/sprout/issues/2): beta
- [x] [Secure Boot support](https://github.com/edera-dev/sprout/issues/20)
- [x] [Bootloader interface support](https://github.com/edera-dev/sprout/issues/21)
- [x] [BLS specification conformance](https://github.com/edera-dev/sprout/issues/2)
### Roadmap
- [ ] [Full-featured boot menu](https://github.com/edera-dev/sprout/issues/1)
- [ ] [UKI support](https://github.com/edera-dev/sprout/issues/6): partial
- [ ] Network boot of unified kernel images (`uki-url`) and devicetree overlays
- [ ] A random seed for the Linux kernel
- [ ] [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
@@ -102,27 +111,9 @@ We recommend running Sprout without Secure Boot for development, and with Secure
Sprout is provided as a single EFI binary called `sprout.efi`.
It can be chainloaded from GRUB or other UEFI bootloaders or booted into directly.
Sprout will look for \sprout.toml in the root of the EFI partition it was loaded from.
See [Configuration](#configuration) for how to configure sprout.
Sprout reads its configuration from `\sprout.toml` in the root of the EFI partition it was loaded from.
## Configuration
Sprout is configured using a TOML file at `\sprout.toml` on the root of the EFI partition sprout was booted from.
### Command Line Options
Sprout supports some command line options that can be combined to modify behavior without the configuration file.
```bash
# Boot Sprout with a specific configuration file.
$ sprout.efi --config=\path\to\config.toml
# Boot a specific entry, bypassing the menu.
$ sprout.efi --boot="Boot Xen"
# Autoconfigure Sprout, without loading a configuration file.
$ sprout.efi --autoconfigure
```
### Boot Linux from ESP
This is a configuration that boots a Linux kernel from the EFI partition:
```toml
# sprout configuration: version 1
@@ -144,24 +135,24 @@ chainload.options = ["root=/dev/sda1"]
chainload.linux-initrd = "\\initrd"
```
### Bootloader Specification (BLS) Support
On a system that follows the Boot Loader Specification, you may not need to write one. With an ext4 driver
loaded, autoconfiguration finds the entries on its own:
```toml
# sprout configuration: version 1
version = 1
# load an EFI driver for ext4.
[drivers.ext4]
path = "\\sprout\\drivers\\ext4.efi"
# global options.
[options]
# enable autoconfiguration by detecting bls enabled
# filesystems and generating boot entries for them.
autoconfigure = true
```
The full list of settings, the command line options, generators, and how Sprout treats BLS entries,
`loader.conf` and strict mode are in [the configuration reference](./docs/config.md).
[Edera]: https://edera.dev
[Configuration Reference]: ./docs/config.md
[Development Guide]: ./DEVELOPMENT.md
[Contributing Guide]: ./CONTRIBUTING.md
[Sprout License]: ./LICENSE
+36
View File
@@ -0,0 +1,36 @@
# Releasing Sprout
This describes how a maintainer cuts a release of Sprout. A release is a signed tag plus a GitHub release
that holds the built EFI binaries and their attestations.
The release is built and published by the `release` workflow, so the release is never visible
until its artifacts are attached.
## Steps
1. Bump `version` in the `[workspace.package]` section of `Cargo.toml` to the new version, then run
`./hack/assemble.sh` to update `Cargo.lock`.
2. Open a pull request with the result. The commit and the PR title are `sprout: version x.y.z`, which is the
one place we do not use a conventional commit. Once squashed, the commit on `main` is
`sprout: version x.y.z (#PR_NUMBER)`.
3. Once the PR has merged, create a signed tag on the merged commit. The tag message is just the tag name.
```bash
git tag -s v0.0.8 -m v0.0.8
git push origin v0.0.8
```
4. Draft a new release on GitHub for that tag, named after the tag, and click Generate Release Notes.
Add a blank line after every section header, as GitHub does not always do that. **Do not click Publish.**
5. Run the `release` workflow from the Actions tab, selecting the tag as the ref to run from, and set the
`Release Tag` input to the tag name (for example `v0.0.8`). The workflow builds the tagged commit,
uploads the binaries to the draft release, attests them, and then publishes the release.
## Notes
- The release workflow builds the tag, not the branch it was dispatched on, so check the tag points at the
version bump commit before running it.
- Re-running the workflow for the same tag replaces the uploaded assets, and runs for one tag never overlap.
+18
View File
@@ -0,0 +1,18 @@
# This crate explicitly does not have uefi/uefi-raw dependencies,
# so that the contents can be unit-testable on non-UEFI target hosts.
# Do not add uefi or uefi-raw as dependencies.
[package]
name = "edera-sprout-bls"
description = "Sprout BLS Utilities (UEFI-free)"
license.workspace = true
version.workspace = true
homepage.workspace = true
repository.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
[lib]
name = "edera_sprout_bls"
path = "src/lib.rs"
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
/// The menu timeout of a `loader.conf` file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoaderTimeout {
/// Show the menu for this many seconds. Zero hides the menu.
Seconds(u64),
/// Hide the menu unless a key is held.
Hidden,
/// Never show the menu.
Disabled,
/// Always show the menu and wait for the user.
Force,
}
impl LoaderTimeout {
/// Parses a `timeout` value, which is a number of seconds or one of the menu words.
fn parse(value: &str) -> Option<Self> {
match value {
"menu-hidden" => Some(Self::Hidden),
"menu-disabled" => Some(Self::Disabled),
"menu-force" => Some(Self::Force),
_ if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => {
value.parse().ok().map(Self::Seconds)
}
_ => None,
}
}
}
/// What to do when the selected entry fails to start, from the `reboot-on-error` setting.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum RebootOnError {
/// Never reboot.
No,
/// Always reboot, which can loop forever if the entry never starts.
Yes,
/// Reboot only if a boot counter try was just used up and tries were left, so that the
/// entry eventually runs out of tries and is not booted any more.
#[default]
Auto,
}
impl RebootOnError {
/// Parses a `reboot-on-error` value, which is `auto` or a boolean. The words are case
/// sensitive, as in systemd-boot.
fn parse(value: &str) -> Option<Self> {
match value {
"auto" => Some(Self::Auto),
"1" | "yes" | "y" | "true" | "t" | "on" => Some(Self::Yes),
"0" | "no" | "n" | "false" | "f" | "off" => Some(Self::No),
_ => None,
}
}
/// Whether to reboot after the entry failed to start. `consumed_tries_left` is the number of
/// tries that were left before this boot used one up, or None if the entry has no boot
/// counter or the try could not be used up, as then a reboot would not make progress.
pub fn should_reboot(self, consumed_tries_left: Option<u32>) -> bool {
match self {
Self::Yes => true,
Self::No => false,
Self::Auto => consumed_tries_left.is_some_and(|left| left > 0),
}
}
}
/// The settings Sprout understands from a `loader.conf` file.
/// Reference: <https://www.freedesktop.org/software/systemd/man/latest/loader.conf.html>
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct LoaderConf {
/// A glob pattern that selects the default entry, or `@saved`.
pub default: Option<String>,
/// A glob pattern that selects the preferred entry, or `@saved`. Unlike `default`, an entry
/// with no boot counter tries left is not used.
pub preferred: Option<String>,
/// How long to show the boot menu.
pub timeout: Option<LoaderTimeout>,
/// What to do when the selected entry fails to start.
pub reboot_on_error: RebootOnError,
/// Problems found while parsing, such as keys that are not supported.
pub warnings: Vec<String>,
}
/// Removes one pair of matching single or double quotes around `value`.
fn unquote(value: &str) -> &str {
for quote in ['"', '\''] {
if let Some(inner) = value
.strip_prefix(quote)
.and_then(|rest| rest.strip_suffix(quote))
{
return inner;
}
}
value
}
impl LoaderConf {
/// Parses the `input` as a `loader.conf` file. Parsing never fails, as a bad setting
/// should not prevent booting. Anything that is ignored is described in `warnings`.
/// When a key appears more than once, the last value is used.
pub fn parse(input: &str) -> Self {
let mut conf = Self::default();
// Some editors write a UTF-8 byte order mark at the start of the file.
let input = input.strip_prefix('\u{feff}').unwrap_or(input);
for line in input.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = match line.split_once(char::is_whitespace) {
Some((key, value)) => (key, unquote(value.trim())),
None => (line, ""),
};
match key {
"default" if !value.is_empty() => conf.default = Some(value.to_string()),
"preferred" if !value.is_empty() => conf.preferred = Some(value.to_string()),
"timeout" => match LoaderTimeout::parse(value) {
Some(timeout) => conf.timeout = Some(timeout),
None => conf
.warnings
.push(format!("ignoring invalid loader.conf timeout '{}'", value)),
},
"reboot-on-error" => match RebootOnError::parse(value) {
Some(reboot_on_error) => conf.reboot_on_error = reboot_on_error,
None => conf.warnings.push(format!(
"ignoring invalid loader.conf reboot-on-error '{}'",
value
)),
},
"default" | "preferred" => conf
.warnings
.push(format!("ignoring loader.conf {} without a value", key)),
_ => conf
.warnings
.push(format!("ignoring unsupported loader.conf key '{}'", key)),
}
}
conf
}
}
+159
View File
@@ -0,0 +1,159 @@
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use anyhow::{Result, bail};
/// The largest section that will be read. UKI metadata sections are small, and a limit
/// keeps a corrupt image from causing a large allocation.
pub const MAX_SECTION_SIZE: u64 = 64 * 1024;
/// The machine type of an x86_64 PE image.
pub const PE_MACHINE_X86_64: u16 = 0x8664;
/// The machine type of an aarch64 PE image.
pub const PE_MACHINE_AARCH64: u16 = 0xaa64;
/// The most sections that are read from a PE image. A unified kernel image with many profiles
/// has many more sections than the 96 that Windows allows, and the table is still small.
const MAX_SECTIONS: usize = 1024;
/// The size of a section table entry.
const SECTION_ENTRY_SIZE: usize = 40;
/// A source of bytes that can be read at an offset, such as a file.
pub trait ReadAt {
/// Fills `buf` with the bytes at `offset`, failing if they can't all be read.
fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()>;
}
/// Reads from a slice of bytes, which is useful for images that are already in memory.
impl ReadAt for &[u8] {
fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> {
let start = usize::try_from(offset)?;
let end = start.checked_add(buf.len());
match end.and_then(|end| self.get(start..end)) {
Some(bytes) => {
buf.copy_from_slice(bytes);
Ok(())
}
None => bail!("read past the end of the image"),
}
}
}
fn u16_at(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
fn u32_at(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
/// The parts of a PE image that were read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeImage {
/// The machine type from the COFF header, such as [PE_MACHINE_X86_64].
pub machine: u16,
/// The wanted sections, in the order of the section table, including repeated names.
pub sections: Vec<PeSection>,
}
/// A wanted section of a PE image.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeSection {
/// The name of the section, such as `.osrel`.
pub name: String,
/// The contents of the section, or None if it is larger than [MAX_SECTION_SIZE]. A section
/// without contents is still there, as it takes the place of a section with the same name.
pub data: Option<Vec<u8>>,
}
/// Reads the sections named in `wanted` from the PE image in `reader`, keeping the first
/// section of each name. See [read_pe] for the details.
pub fn read_sections<R: ReadAt>(
reader: &mut R,
wanted: &[&str],
) -> Result<BTreeMap<String, Vec<u8>>> {
let mut sections = BTreeMap::new();
for section in read_pe(reader, wanted)?.sections {
sections.entry(section.name).or_insert(section.data);
}
// A section that was too large is not available.
Ok(sections
.into_iter()
.filter_map(|(name, data)| data.map(|data| (name, data)))
.collect())
}
/// Reads the machine type and the sections named in `wanted` from the PE image in `reader`.
/// Only the headers and the wanted sections are read, so this is cheap on large images.
/// A wanted section that is larger than [MAX_SECTION_SIZE] is listed without its contents.
/// An image that isn't a valid PE file is an error.
pub fn read_pe<R: ReadAt>(reader: &mut R, wanted: &[&str]) -> Result<PeImage> {
// The DOS header starts with "MZ" and holds the offset of the PE header at 0x3c.
let mut dos = [0u8; 0x40];
reader.read_at(0, &mut dos)?;
if &dos[..2] != b"MZ" {
bail!("not a PE image: missing the MZ signature");
}
let pe_offset = u64::from(u32_at(&dos, 0x3c));
// The PE header is the "PE\0\0" signature followed by the COFF header.
let mut coff = [0u8; 24];
reader.read_at(pe_offset, &mut coff)?;
if &coff[..4] != b"PE\0\0" {
bail!("not a PE image: missing the PE signature");
}
let machine = u16_at(&coff, 4);
let section_count = usize::from(u16_at(&coff, 6));
let optional_header_size = u64::from(u16_at(&coff, 20));
if section_count > MAX_SECTIONS {
bail!("PE image has too many sections: {}", section_count);
}
// The section table follows the optional header.
let mut table = vec![0u8; section_count * SECTION_ENTRY_SIZE];
reader.read_at(pe_offset + 24 + optional_header_size, &mut table)?;
let mut sections = Vec::new();
for entry in table.as_chunks::<SECTION_ENTRY_SIZE>().0 {
// The name is up to eight bytes, padded with NUL.
let name_len = entry[..8].iter().position(|b| *b == 0).unwrap_or(8);
let Ok(name) = core::str::from_utf8(&entry[..name_len]) else {
continue;
};
if !wanted.contains(&name) {
continue;
}
// The real size is the virtual size, as the raw size is padded to the file alignment.
let virtual_size = u64::from(u32_at(entry, 8));
let raw_size = u64::from(u32_at(entry, 16));
let raw_offset = u64::from(u32_at(entry, 20));
// A virtual size of zero makes the section empty, which is how a profile removes a section
// of the base image.
let size = virtual_size.min(raw_size);
if size > MAX_SECTION_SIZE {
sections.push(PeSection {
name: name.to_string(),
data: None,
});
continue;
}
let mut data = vec![0u8; size as usize];
reader.read_at(raw_offset, &mut data)?;
sections.push(PeSection {
name: name.to_string(),
data: Some(data),
});
}
Ok(PeImage { machine, sections })
}
+273
View File
@@ -0,0 +1,273 @@
use crate::{BlsEntry, PeSection};
use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
/// The PE sections of a unified kernel image that Sprout reads.
pub const UKI_SECTIONS: [&str; 4] = [".osrel", ".cmdline", ".uname", ".profile"];
/// The most profiles that are read from a unified kernel image.
pub const MAX_PROFILES: usize = 256;
/// A parsed os-release file, such as the `.osrel` section of a unified kernel image.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct OsRelease {
fields: BTreeMap<String, String>,
}
impl OsRelease {
/// Parses the `input` as an os-release file, which has one `KEY=value` per line.
/// Lines that aren't a setting are ignored, and the last value of a key is used.
pub fn parse(input: &str) -> Self {
let mut fields = BTreeMap::new();
for line in input.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
fields.insert(key.trim().to_string(), unquote(value.trim()));
}
Self { fields }
}
/// Fetches the value of `key`, if it is set.
pub fn get(&self, key: &str) -> Option<&str> {
self.fields.get(key).map(String::as_str)
}
}
/// Removes one pair of matching quotes from `value` and unescapes a double quoted value.
fn unquote(value: &str) -> String {
if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') {
return value[1..value.len() - 1].to_string();
}
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
let mut result = String::new();
let mut chars = value[1..value.len() - 1].chars();
while let Some(c) = chars.next() {
// Only these characters can be escaped. Any other backslash is kept as written.
match (c, chars.clone().next()) {
('\\', Some(next @ ('"' | '\\' | '$' | '`'))) => {
result.push(next);
chars.next();
}
_ => result.push(c),
}
}
return result;
}
value.to_string()
}
/// Decodes a section as text, dropping the NUL and whitespace padding at the end.
/// An empty section is treated as missing.
fn section_text(sections: &BTreeMap<String, Vec<u8>>, name: &str) -> Option<String> {
let text = String::from_utf8_lossy(sections.get(name)?);
let text = text.trim_end_matches(|c: char| c == '\0' || c.is_whitespace());
(!text.is_empty()).then(|| text.to_string())
}
impl BlsEntry {
/// Produces an entry for one profile of the unified kernel image at `uki_path`.
/// A profile after the first is booted with `@<number>` in its load options.
pub fn from_uki_profile(profile: &UkiProfile, uki_path: &str) -> Self {
Self::from_uki_profile_with(profile, uki_path, false)
}
/// Like [BlsEntry::from_uki_profile], and `strict` follows systemd-boot exactly.
pub fn from_uki_profile_with(profile: &UkiProfile, uki_path: &str, strict: bool) -> Self {
let mut entry = Self::from_uki_with(&profile.sections, uki_path, strict);
if let Some(index) = profile.index {
entry.profile = (index > 0).then(|| index.to_string());
entry.title = entry
.title
.take()
.map(|title| profile_title(&title, index, &profile.info));
}
entry
}
/// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`.
pub fn from_uki(sections: &BTreeMap<String, Vec<u8>>, uki_path: &str) -> Self {
Self::from_uki_with(sections, uki_path, false)
}
/// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`.
/// With `strict`, the title comes from `PRETTY_NAME`, `IMAGE_ID`, `NAME`, then `ID`, and the
/// version from `IMAGE_VERSION`, `VERSION`, `VERSION_ID`, then `BUILD_ID`, as in systemd-boot.
/// Otherwise the title comes from `PRETTY_NAME`, then `ID`, and the version from
/// `IMAGE_VERSION`, `VERSION_ID`, `BUILD_ID`, then the `.uname` section. The sort key comes
/// from `IMAGE_ID`, then `ID`. The embedded command line is kept in `cmdline` and not in `options`, as the image
/// reads its own command line.
pub fn from_uki_with(
sections: &BTreeMap<String, Vec<u8>>,
uki_path: &str,
strict: bool,
) -> Self {
let os_release = section_text(sections, ".osrel")
.map(|text| OsRelease::parse(&text))
.unwrap_or_default();
let first = |keys: &[&str]| {
keys.iter()
.filter_map(|key| os_release.get(key))
.find(|value| !value.is_empty())
.map(ToString::to_string)
};
let uname = section_text(sections, ".uname");
// systemd-boot looks at more fields. Outside of strict mode the version is not taken from
// VERSION, which is often a long name that would be added to the title.
let (title, version) = if strict {
(
first(&["PRETTY_NAME", "IMAGE_ID", "NAME", "ID"]),
first(&["IMAGE_VERSION", "VERSION", "VERSION_ID", "BUILD_ID"]),
)
} else {
(
first(&["PRETTY_NAME", "ID"]),
first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]),
)
};
Self {
title,
version: version.or_else(|| uname.clone().filter(|_| !strict)),
sort_key: first(&["IMAGE_ID", "ID"]),
uki: Some(uki_path.to_string()),
cmdline: section_text(sections, ".cmdline"),
uname,
..Self::default()
}
}
}
/// The metadata in the `.profile` section of a profile of a unified kernel image.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ProfileInfo {
/// A short identifier of the profile.
pub id: Option<String>,
/// A title for people to read.
pub title: Option<String>,
}
impl ProfileInfo {
/// Parses the text of a `.profile` section, which is in the format of an os-release file.
/// A key without a value is treated as missing.
pub fn parse(text: &str) -> Self {
let os_release = OsRelease::parse(text);
let get = |key: &str| {
os_release
.get(key)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
};
Self {
id: get("ID"),
title: get("TITLE"),
}
}
}
/// The sections that one profile of a unified kernel image boots with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UkiProfile {
/// The number of the profile, or None if the image has no `.profile` sections.
pub index: Option<u32>,
/// The metadata of the profile.
pub info: ProfileInfo,
/// The sections of the profile on top of those of the base image. A section that is empty
/// or too large to read, which is how a profile removes a section, is not here.
pub sections: BTreeMap<String, Vec<u8>>,
}
/// Keeps the first section of each name in `range`.
fn first_of_each(range: &[PeSection]) -> BTreeMap<String, Option<Vec<u8>>> {
let mut sections = BTreeMap::new();
for section in range {
sections
.entry(section.name.clone())
.or_insert_with(|| section.data.clone());
}
sections
}
/// Splits the `sections` of a unified kernel image, in the order of the section table, into the
/// profiles of the image. Each `.profile` section starts a profile that goes on until the next
/// one, and the sections before the first are the base that every profile starts from.
/// A section of a profile takes the place of the section of the same name in the base.
/// An image without `.profile` sections has one profile, which is the base.
pub fn uki_profiles(sections: &[PeSection]) -> Vec<UkiProfile> {
// Sections that are empty or too large are not available.
let usable = |map: BTreeMap<String, Option<Vec<u8>>>| -> BTreeMap<String, Vec<u8>> {
map.into_iter()
.filter_map(|(name, data)| {
data.filter(|data| !data.is_empty())
.map(|data| (name, data))
})
.collect()
};
let starts: Vec<usize> = sections
.iter()
.enumerate()
.filter(|(_, section)| section.name == ".profile")
.map(|(index, _)| index)
.collect();
let Some(&first) = starts.first() else {
return alloc::vec![UkiProfile {
index: None,
info: ProfileInfo::default(),
sections: usable(first_of_each(sections)),
}];
};
let base = first_of_each(&sections[..first]);
starts
.iter()
.take(MAX_PROFILES)
.enumerate()
.map(|(index, start)| {
let end = starts.get(index + 1).copied().unwrap_or(sections.len());
let range = &sections[*start..end];
let mut merged = base.clone();
merged.extend(first_of_each(range));
UkiProfile {
index: Some(index as u32),
info: range[0]
.data
.as_ref()
.map(|data| ProfileInfo::parse(&String::from_utf8_lossy(data)))
.unwrap_or_default(),
sections: usable(merged),
}
})
.collect()
}
/// The part of the id of an entry that comes after the `@`, which names the profile.
/// The first profile has none, and the others have their identifier or their number.
pub fn profile_id_suffix(index: u32, info: &ProfileInfo) -> Option<String> {
(index > 0).then(|| info.id.clone().unwrap_or_else(|| index.to_string()))
}
/// The title of the entry for a profile, given the `name` of the image.
pub fn profile_title(name: &str, index: u32, info: &ProfileInfo) -> String {
match (&info.title, &info.id) {
(Some(title), _) => format!("{} ({})", name, title),
(None, Some(id)) if index > 0 => format!("{} ({})", name, id),
(None, None) if index > 0 => format!("{} (Profile #{})", name, index + 1),
_ => name.to_string(),
}
}
/// The id of the entry for a profile of the image with the file name `file_id`, such as
/// `fedora.efi`. The file name is in lower case, as in systemd-boot, and the profile is not.
pub fn profile_entry_id(file_id: &str, suffix: Option<&str>) -> String {
match suffix {
Some(suffix) => format!("{}@{}", file_id.to_lowercase(), suffix),
None => file_id.to_lowercase(),
}
}
+5
View File
@@ -11,7 +11,11 @@ edition.workspace = true
anyhow.workspace = true
edera-sprout-config.path = "../config"
edera-sprout-eficore.path = "../eficore"
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
@@ -24,3 +28,4 @@ edera-sprout-build.path = "../build"
[[bin]]
name = "sprout"
path = "src/main.rs"
test = false
+4 -1
View File
@@ -1,7 +1,10 @@
use edera_sprout_build::generate_sbat_module;
use edera_sprout_build::{generate_logo_module, generate_sbat_module};
/// Build script entry point for Sprout.
fn main() {
// Generate the sbat.generated.rs file.
generate_sbat_module();
// Generate the logo.generated.rs file.
generate_logo_module();
}
+194 -37
View File
@@ -1,27 +1,112 @@
use crate::context::SproutContext;
use crate::utils;
use crate::generators::bls::BLS_INITRD_SLOTS;
use crate::phases::before_handoff;
use alloc::boxed::Box;
use alloc::format;
use alloc::rc::Rc;
use alloc::vec::Vec;
use anyhow::{Context, Result, bail};
use edera_sprout_config::actions::chainload::ChainloadConfiguration;
use edera_sprout_parsing::{append_initrd, combine_options, empty_is_none};
use eficore::bootloader_interface::BootloaderInterface;
use eficore::devicetree::DeviceTree;
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 log::error;
use uefi::CString16;
use eficore::path::ResolvedPath;
use eficore::secure::SecureBoot;
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>>> {
read_optional_file(context, path, "initrd")
}
/// Read the `what` 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.
fn read_optional_file(
context: &Rc<SproutContext>,
path: &str,
what: &str,
) -> Result<Option<Vec<u8>>> {
let Some(resolved) = resolve_optional_file(context, path, what)? else {
return Ok(None);
};
let content = resolved
.read_file()
.with_context(|| format!("unable to read {}", what))?;
Ok(Some(content))
}
/// Resolve the `what` 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.
fn resolve_optional_file(
context: &Rc<SproutContext>,
path: &str,
what: &str,
) -> Result<Option<ResolvedPath>> {
let resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), path)
.with_context(|| format!("unable to resolve {} path", what))?;
// A path without a file component refers to the root of the filesystem, not a file.
// This happens when a path template like "$root\\$initrd-0" is stamped with an empty
// 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)
.with_context(|| format!("unable to get {} subpath", what))?;
if subpath.trim_matches('\\').is_empty() {
return Ok(None);
}
Ok(Some(resolved))
}
/// Install the devicetree at the stamped `path` for the image that is about to be started.
/// Provides [None] if there is none to install, such as when the path refers to the root of a
/// filesystem, or when Secure Boot is enabled, as a devicetree can't be verified.
fn install_devicetree(context: &Rc<SproutContext>, path: &str) -> Result<Option<DeviceTree>> {
let Some(resolved) = resolve_optional_file(context, path, "devicetree")? else {
return Ok(None);
};
if SecureBoot::enabled().unwrap_or(true) {
warn!("ignoring the devicetree, as Secure Boot is enabled");
return Ok(None);
}
let content = resolved.read_file().context("unable to read devicetree")?;
let devicetree = DeviceTree::install(&content).context("unable to install the devicetree")?;
Ok(Some(devicetree))
}
/// Unloads the image with the contained handle when dropped, unless the handle is taken.
/// This ensures that an image that is loaded but never started is not left in memory.
struct UnloadGuard(Option<Handle>);
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();
// Resolve the path to the image to chainload.
let resolved = eficore::path::resolve_path(
Some(context.root().loaded_image_path()?),
&context.stamp(&configuration.path),
context.stamp(&configuration.path),
)
.context("unable to resolve chainload path")?;
@@ -32,14 +117,11 @@ 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)?;
// Open the LoadedImage protocol of the image to chainload.
let mut loaded_image_protocol =
uefi::boot::open_protocol_exclusive::<LoadedImage>(*image.handle())
.context("unable to open loaded image protocol")?;
// 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 =
utils::combine_options(configuration.options.iter().map(|item| context.stamp(item)));
let options = combine_options(context.stamp_iter(configuration.options.iter()));
// Pass the load options to the image.
// If no options are provided, the resulting string will be empty.
@@ -50,43 +132,119 @@ pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfigurat
.context("unable to convert chainloader options to CString16")?,
);
// Ensure the chainloader options limit is not exceeded.
if options.num_bytes() > u32::MAX as usize {
bail!("chainloader options too large");
}
// SAFETY: option size is checked to validate it is safe to pass.
// Additionally, the pointer is allocated and retained on heap, which makes
// passing the `options` pointer safe to the next image.
unsafe {
loaded_image_protocol
.set_load_options(options.as_ptr() as *const u8, options.num_bytes() as u32);
// Open the LoadedImage protocol of the image to chainload to pass the load options.
// This is done in a block to release the protocol before the image is started, as the
// image may itself need to open its LoadedImage protocol exclusively.
{
let mut loaded_image_protocol =
uefi::boot::open_protocol_exclusive::<LoadedImage>(*image.handle())
.context("unable to open loaded image protocol")?;
// SAFETY: option size is checked to validate it is safe to pass.
// Additionally, the pointer is allocated and retained on heap, which makes
// passing the `options` pointer safe to the next image.
unsafe {
loaded_image_protocol
.set_load_options(options.as_ptr() as *const u8, options.num_bytes() as u32);
}
}
// 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 = utils::empty_is_none(initrd);
// If an initrd is provided, register it with the EFI stack.
// A BLS entry can have more initrds than the chain lists. Those would be dropped without a
// word, and then the kernel may not find its root, so say so.
let chain_length = configuration.linux_initrd_chain.len();
if chain_length > 0 {
for slot in chain_length..BLS_INITRD_SLOTS {
let placeholder = format!("$initrd-{}", slot);
let value = context.stamp(&placeholder);
if !value.is_empty() && value != placeholder {
warn!(
"the entry has an initrd for slot {}, but linux-initrd-chain only lists {}, \
so it is not loaded",
slot, chain_length
);
break;
}
}
}
// Read each initrd and concatenate the contents in order.
// Paths that are empty after stamping are skipped.
let mut initrd: Option<Vec<u8>> = None;
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(linux_initrd) = initrd {
let content = eficore::path::read_file_contents(
Some(context.root().loaded_image_path()?),
&linux_initrd,
)
.context("unable to read linux initrd")?;
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);
}
// Install the devicetree, if there is one, for the image to find. It is restored when it is
// dropped, which is once the image has returned or failed to start. A devicetree is not
// verified, so it is not used when Secure Boot is enabled, or when that can't be told.
let mut devicetree = None;
if let Some(path) = empty_is_none(
configuration
.devicetree
.as_ref()
.map(|path| context.stamp(path)),
) {
match install_devicetree(&context, &path) {
Ok(installed) => devicetree = installed,
// systemd-boot does not boot an entry whose devicetree can't be installed. Outside
// of strict mode it is booted without it, as before the devicetree was supported.
Err(error) if !context.root().options().bls_strict_mode => warn!(
"booting without the devicetree {}, as it can't be installed: {:#} \
(systemd-boot and strict mode do not boot the entry)",
path, error
),
Err(error) => return Err(error),
}
}
// 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.
@@ -94,19 +252,18 @@ pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfigurat
// after the optional initrd has been unregistered.
let result = uefi::boot::start_image(*image.handle());
// Unregister the initrd if it was registered.
if let Some(initrd_handle) = initrd_handle
&& let Err(error) = initrd_handle.unregister()
{
error!("unable to unregister linux initrd: {}", error);
}
// Assert there was no error starting the image.
result.context("unable to start image")?;
// Explicitly drop the options to clarify the lifetime.
drop(options);
// Explicitly drop the initrd handle to clarify when it should be unregistered.
drop(initrd_handle);
// Explicitly drop the devicetree to clarify when the original is restored.
drop(devicetree);
// Return control to sprout.
Ok(())
}
+97 -91
View File
@@ -1,119 +1,127 @@
use crate::{
actions,
context::SproutContext,
utils::{self},
};
use crate::{actions, context::SproutContext};
use alloc::rc::Rc;
use alloc::string::{String, ToString};
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::{append_initrd, build_xen_config, combine_options, empty_is_none};
use eficore::media_loader::{
MediaLoaderHandle,
constants::xen::{
XEN_EFI_CONFIG_MEDIA_GUID, XEN_EFI_KERNEL_MEDIA_GUID, XEN_EFI_RAMDISK_MEDIA_GUID,
},
};
use log::error;
use eficore::platform::tpm::PlatformTpm;
use uefi::Guid;
/// Builds a configuration string for the Xen EFI stub using the specified `configuration`.
fn build_xen_config(context: Rc<SproutContext>, configuration: &EderaConfiguration) -> String {
// Stamp xen options and combine them.
let xen_options = utils::combine_options(
configuration
.xen_options
.iter()
.map(|item| context.stamp(item)),
);
// Stamp kernel options and combine them.
let kernel_options = utils::combine_options(
configuration
.kernel_options
.iter()
.map(|item| context.stamp(item)),
);
// xen config file format is ini-like
[
// global section
"[global]".to_string(),
// default configuration section
"default=sprout".to_string(),
// configuration section for sprout
"[sprout]".to_string(),
// xen options
format!("options={}", xen_options),
// kernel options, stub replaces the kernel path
// the kernel is provided via media loader
format!("kernel=stub {}", kernel_options),
// required or else the last line will be ignored
"".to_string(),
]
.join("\n")
}
/// Register a media loader for some `text` with the vendor `guid`.
/// Register a media loader for the provided `bytes` with the vendor `guid`.
/// `what` should indicate some identifying value for error messages
/// like `config` or `kernel`.
/// Provides a [MediaLoaderHandle] that can be used to unregister the media loader.
fn register_media_loader_text(guid: Guid, what: &str, text: String) -> Result<MediaLoaderHandle> {
MediaLoaderHandle::register(guid, text.as_bytes().to_vec().into_boxed_slice())
.context(format!("unable to register {} media loader", what)) /* */
}
/// Register a media loader for the file `path` with the vendor `guid`.
/// `what` should indicate some identifying value for error messages
/// like `config` or `kernel`.
/// Provides a [MediaLoaderHandle] that can be used to unregister the media loader.
fn register_media_loader_file(
context: &Rc<SproutContext>,
fn register_media_loader_bytes(
guid: Guid,
what: &str,
path: &str,
bytes: Vec<u8>,
) -> Result<MediaLoaderHandle> {
// Stamp the path to the file.
MediaLoaderHandle::register(guid, bytes.into_boxed_slice())
.context(format!("unable to register {} media loader", what))
}
/// Read the contents of the loader payload at `path` relative to the sprout image.
/// `what` should indicate some identifying value for error messages
/// like `kernel` or `initrd`.
fn read_loader_payload(context: &Rc<SproutContext>, what: &str, path: &str) -> Result<Vec<u8>> {
let path = context.stamp(path);
// Read the file contents.
let content =
eficore::path::read_file_contents(Some(context.root().loaded_image_path()?), &path)
.context(format!("unable to read {} file", what))?;
// Register the media loader.
let handle = MediaLoaderHandle::register(guid, content.into_boxed_slice())
.context(format!("unable to register {} media loader", what))?;
Ok(handle)
eficore::path::read_file_contents(Some(context.root().loaded_image_path()?), &path)
.context(format!("unable to read {} file", what))
}
/// 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<()> {
// Build the Xen config file content for this configuration.
let config = build_xen_config(context.clone(), configuration);
// 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");
}
// Register the media loader for the config.
let config = register_media_loader_text(XEN_EFI_CONFIG_MEDIA_GUID, "config", config)
.context("unable to register config media loader")?;
// 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()));
// Register the media loaders for the kernel.
let kernel = register_media_loader_file(
&context,
XEN_EFI_KERNEL_MEDIA_GUID,
"kernel",
&configuration.kernel,
// Measure xen options and dom0 cmdline into PCR 12 in a fixed order.
PlatformTpm::log_event(
PlatformTpm::PCR_KERNEL_PARAMETERS,
xen_opts.as_bytes(),
"sprout: xen options",
)
.context("unable to register kernel media loader")?;
.context("unable to measure xen options into the TPM")?;
PlatformTpm::log_event(
PlatformTpm::PCR_KERNEL_PARAMETERS,
dom0_args.as_bytes(),
"sprout: dom0 cmdline",
)
.context("unable to measure dom0 cmdline into the TPM")?;
// Create a vector of media loaders to unregister on error.
let mut media_loaders = vec![config, kernel];
// Build the Xen config text and register it as the config media loader. The
// assembled text is a derived artifact, intentionally not measured.
let config_handle = register_media_loader_bytes(
XEN_EFI_CONFIG_MEDIA_GUID,
"config",
build_xen_config(&xen_opts, &dom0_args).into_bytes(),
)
.context("unable to register config media loader")?;
// Read the dom0 kernel, measure it into PCR 11, then register the kernel
// media loader.
let kernel_bytes = read_loader_payload(&context, "kernel", &configuration.kernel)?;
PlatformTpm::log_event(
PlatformTpm::PCR_KERNEL,
&kernel_bytes,
"sprout: dom0 kernel",
)
.context("unable to measure dom0 kernel into the TPM")?;
let kernel_handle =
register_media_loader_bytes(XEN_EFI_KERNEL_MEDIA_GUID, "kernel", kernel_bytes)
.context("unable to register kernel media loader")?;
// 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,
"sprout: dom0 initrd",
)
.context("unable to measure dom0 initrd into the TPM")?;
// Create a vector of media loaders to drop them only after this function completes.
let mut media_loaders = vec![config_handle, kernel_handle];
// Register the initrd if it is provided.
if let Some(initrd) = utils::empty_is_none(configuration.initrd.as_ref()) {
let initrd =
register_media_loader_file(&context, XEN_EFI_RAMDISK_MEDIA_GUID, "initrd", initrd)
if !initrd_bytes.is_empty() {
let initrd_handle =
register_media_loader_bytes(XEN_EFI_RAMDISK_MEDIA_GUID, "initrd", initrd_bytes)
.context("unable to register initrd media loader")?;
media_loaders.push(initrd);
media_loaders.push(initrd_handle);
}
// Chainload to the Xen EFI stub.
@@ -123,16 +131,14 @@ pub fn edera(context: Rc<SproutContext>, configuration: &EderaConfiguration) ->
path: configuration.xen.clone(),
options: vec![],
linux_initrd: None,
linux_initrd_chain: vec![],
devicetree: None,
},
)
.context("unable to chainload to xen");
// Unregister the media loaders when an error happens.
for media_loader in media_loaders {
if let Err(error) = media_loader.unregister() {
error!("unable to unregister media loader: {}", error);
}
}
// Explicitly drop the media loaders to clarify when they should be unregistered.
drop(media_loaders);
result
}
+116 -15
View File
@@ -1,7 +1,11 @@
use alloc::boxed::Box;
use anyhow::{Context, Result};
use edera_sprout_config::RootConfiguration;
use log::warn;
use uefi::Handle;
use uefi::fs::FileSystem;
use uefi::proto::device_path::DevicePath;
use uefi::proto::loaded_image::LoadedImage;
use uefi::proto::media::fs::SimpleFileSystem;
/// bls: autodetect and configure BLS-enabled filesystems.
@@ -15,42 +19,139 @@ pub mod linux;
/// windows: autodetect and configure Windows boot configurations.
pub mod windows;
/// Find the filesystems that systemd-boot reads BLS entries from: the partition that Sprout
/// was loaded from, and the extended boot loader partition of the same disk.
fn strict_bls_sources(loaded_image_path: &DevicePath) -> (Option<Handle>, Option<Handle>) {
let own = eficore::handle::open_shared::<LoadedImage>(uefi::boot::image_handle())
.ok()
.and_then(|image| image.device());
let xbootldr = match eficore::xbootldr::find_xbootldr(loaded_image_path) {
Ok(Some(path)) => uefi::boot::locate_device_path::<SimpleFileSystem>(&mut &*path).ok(),
Ok(None) => None,
Err(error) => {
warn!("unable to find the xbootldr partition: {:#}", error);
None
}
};
(own, xbootldr)
}
/// Generate a [RootConfiguration] based on the environment.
/// Intakes a `config` to use as the basis of the autoconfiguration.
pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> {
/// With `strict`, only the partition Sprout was loaded from and the extended boot loader
/// partition of its disk are read for BLS entries, as one generator, like systemd-boot does.
pub fn autoconfigure(
config: &mut RootConfiguration,
strict: bool,
loaded_image_path: &DevicePath,
) -> Result<()> {
// Find all the filesystems that are on the system.
let filesystem_handles =
uefi::boot::find_handles::<SimpleFileSystem>().context("unable to scan filesystems")?;
// The partitions that BLS entries are read from in strict mode.
let (own_handle, xbootldr_handle) = if strict {
strict_bls_sources(loaded_image_path)
} else {
(None, None)
};
// The device path of the partition of Sprout, which names the strict generator.
let mut own_root: Option<Box<DevicePath>> = None;
// Whether either of the partitions has BLS entries, in strict mode.
let mut strict_found = false;
// For each filesystem that was detected, scan it for supported autoconfig mechanisms.
for handle in filesystem_handles {
// Acquire the device path root for the filesystem.
let root = {
uefi::boot::open_protocol_exclusive::<DevicePath>(handle)
.context("unable to get root for filesystem")?
.to_boxed()
// A filesystem that can't be opened is skipped, as it should not prevent
// autoconfiguring the other filesystems.
let root = match uefi::boot::open_protocol_exclusive::<DevicePath>(handle) {
Ok(root) => root.to_boxed(),
Err(error) => {
warn!("skipping filesystem, unable to get its root: {}", error);
continue;
}
};
// Open the filesystem that was detected.
let filesystem = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(handle)
.context("unable to open filesystem")?;
let filesystem = match uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(handle) {
Ok(filesystem) => filesystem,
Err(error) => {
warn!("skipping filesystem, unable to open it: {}", error);
continue;
}
};
// Trade the filesystem protocol for the uefi filesystem helper.
let mut filesystem = FileSystem::new(filesystem);
// Scan the filesystem for BLS supported configurations.
let bls_found = bls::scan(&mut filesystem, &root, config)
.context("unable to scan for bls configurations")?;
// 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. In strict mode, only the
// partitions that systemd-boot reads get entries, and they get a single generator.
// The other filesystems that have BLS are still not scanned for Linux configurations.
let is_source = Some(handle) == own_handle || Some(handle) == xbootldr_handle;
if strict && Some(handle) == own_handle {
own_root = Some(root.to_boxed());
}
let bls_found = if strict {
match bls::detect(&mut filesystem) {
Ok(found) => {
strict_found |= found && is_source;
found
}
Err(error) => {
warn!(
"unable to scan filesystem for bls configurations: {:#}",
error
);
false
}
}
} else {
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
);
}
}
// In strict mode, one generator reads both partitions, with the root of Sprout's partition.
if strict && strict_found {
match own_root {
Some(root) => {
if let Err(error) = bls::add_generator(config, &root, true) {
warn!("unable to add the bls generator: {:#}", error);
}
}
None => warn!(
"found BLS entries, but not the partition that Sprout was loaded from, \
so none are used in strict mode"
),
}
}
Ok(())
+90 -29
View File
@@ -1,4 +1,4 @@
use crate::utils;
use crate::generators::bls::BLS_INITRD_SLOTS;
use alloc::string::ToString;
use alloc::{format, vec};
use anyhow::{Context, Result};
@@ -8,6 +8,7 @@ use edera_sprout_config::actions::chainload::ChainloadConfiguration;
use edera_sprout_config::entries::EntryDeclaration;
use edera_sprout_config::generators::GeneratorDeclaration;
use edera_sprout_config::generators::bls::BlsConfiguration;
use edera_sprout_parsing::unique_hash;
use uefi::cstr16;
use uefi::fs::{FileSystem, Path};
use uefi::proto::device_path::DevicePath;
@@ -17,27 +18,14 @@ use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
/// by the BLS generator to chainload entries.
const BLS_CHAINLOAD_ACTION_PREFIX: &str = "bls-chainload-";
/// Scan the specified `filesystem` for BLS configurations.
pub fn scan(
filesystem: &mut FileSystem,
root: &DevicePath,
config: &mut RootConfiguration,
) -> Result<bool> {
/// Detect whether the `filesystem` has a BLS supported configuration.
pub fn detect(filesystem: &mut FileSystem) -> Result<bool> {
// BLS has a loader.conf file that can specify its own auto-entries mechanism.
let bls_loader_conf_path = Path::new(cstr16!("\\loader\\loader.conf"));
// BLS also has an entries directory that can specify explicit entries.
let bls_entries_path = Path::new(cstr16!("\\loader\\entries"));
// Convert the device path root to a string we can use in the configuration.
let mut root = root
.to_string(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert device root to string")?
.to_string();
// Add a trailing forward-slash to the root to ensure the device root is completed.
root.push('/');
// Generate a unique hash of the root path.
let root_unique_hash = utils::unique_hash(&root);
// BLS Type #2 entries are unified kernel images in the EFI/Linux directory.
let bls_uki_path = Path::new(cstr16!("\\EFI\\Linux"));
// Whether we have a loader.conf file.
let has_loader_conf = filesystem
@@ -45,19 +33,64 @@ 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);
// Whether we have any unified kernel images.
let has_ukis = filesystem
.read_dir(bls_uki_path)
.map(|mut iterator| {
iterator.any(|entry| {
entry.is_ok_and(|entry| {
entry.is_regular_file()
&& entry
.file_name()
.to_string()
.to_lowercase()
.ends_with(".efi")
})
})
})
.unwrap_or(false);
// Detect if a BLS supported configuration is on this filesystem.
// We check both loader.conf and entries directory as only one of them is required.
if !(has_loader_conf || has_entries_dir) {
return Ok(false);
}
// We check loader.conf, the entries directory and unified kernel images, as only one of
// them is required.
Ok(has_loader_conf || has_entries_dir || has_ukis)
}
/// Add the BLS generator and its action for the partition with the device path `root`.
/// In `strict` mode, the generator also reads the extended boot loader partition of the disk.
pub fn add_generator(
config: &mut RootConfiguration,
root: &DevicePath,
strict: bool,
) -> Result<()> {
// Convert the device path root to a string we can use in the configuration.
let mut root = root
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert device root to string")?
.to_string();
// Add a trailing forward-slash to the root to ensure the device root is completed.
root.push('/');
// Generate a unique hash of the root path.
let root_unique_hash = unique_hash(&root);
// Generate a unique name for the BLS chainload action.
let chainload_action_name = format!("{}{}", BLS_CHAINLOAD_ACTION_PREFIX, root_unique_hash,);
@@ -70,6 +103,12 @@ pub fn scan(
..Default::default()
},
path: format!("{}\\loader", root),
uki_path: None,
// Every filesystem is scanned, including the Extended Boot Loader Partition, which
// gets a generator of its own, unless strict mode reads it with the generator of the
// partition that Sprout was loaded from.
xbootldr: strict,
pin_names: true,
};
// Generate a unique name for the BLS generator and insert the generator into the configuration.
@@ -83,10 +122,20 @@ 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.
// The files of an entry are on the partition of the entry, which is the root of the
// generator for most entries, and the extended boot loader partition for some in strict mode.
let entry_root = if strict { "$entry-root" } else { &root };
let chainload = ChainloadConfiguration {
path: format!("{}\\$chainload", root),
path: format!("{}\\$chainload", entry_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-{}", entry_root, slot))
.collect(),
// An unset devicetree stamps to the root of the filesystem, which the action skips.
devicetree: Some(format!("{}\\$devicetree", entry_root)),
};
// Insert the chainload action into the configuration.
@@ -98,6 +147,18 @@ pub fn scan(
},
);
// We had a BLS supported configuration, so return true.
Ok(())
}
/// Scan the specified `filesystem` for BLS configurations.
pub fn scan(
filesystem: &mut FileSystem,
root: &DevicePath,
config: &mut RootConfiguration,
) -> Result<bool> {
if !detect(filesystem)? {
return Ok(false);
}
add_generator(config, root, false)?;
Ok(true)
}
+29 -32
View File
@@ -1,5 +1,3 @@
use crate::utils;
use crate::utils::vercmp;
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
@@ -11,6 +9,11 @@ use edera_sprout_config::actions::chainload::ChainloadConfiguration;
use edera_sprout_config::entries::EntryDeclaration;
use edera_sprout_config::generators::GeneratorDeclaration;
use edera_sprout_config::generators::list::ListConfiguration;
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;
@@ -20,16 +23,10 @@ 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", "\\"];
/// Prefixes of kernel files to scan for.
const KERNEL_PREFIXES: &[&str] = &["vmlinuz"];
/// Prefixes of initramfs files to match to.
const INITRAMFS_PREFIXES: &[&str] = &["initramfs", "initrd", "initrd.img"];
/// This is really silly, but if what we are booting is the Canonical stubble stub,
/// there is a chance it will assert that the load options are non-empty.
/// Technically speaking, load options can be empty. However, it assumes load options
@@ -37,9 +34,9 @@ const INITRAMFS_PREFIXES: &[&str] = &["initramfs", "initrd", "initrd.img"];
/// and then uses that code improperly by asserting that the pointer is non-null.
/// To give a good user experience, we place a placeholder value here to ensure it's non-empty.
/// For stubble, this code ensures the command line pointer becomes null:
/// https://github.com/ubuntu/stubble/blob/e56643979addfb98982266018e08921c07424a0c/stub.c#L61-L64
/// <https://github.com/ubuntu/stubble/blob/e56643979addfb98982266018e08921c07424a0c/stub.c#L61-L64>
/// Then this code asserts on it, stopping the boot process:
/// https://github.com/ubuntu/stubble/blob/e56643979addfb98982266018e08921c07424a0c/stub.c#L27
/// <https://github.com/ubuntu/stubble/blob/e56643979addfb98982266018e08921c07424a0c/stub.c#L27>
const DEFAULT_LINUX_OPTIONS: &str = "placeholder";
/// Pair of kernel and initramfs.
@@ -109,9 +106,7 @@ fn scan_directory(filesystem: &mut FileSystem, path: &str) -> Result<Vec<KernelP
// Find a kernel prefix that matches, if any.
// This is case-insensitive to ensure we pick up all possibilities.
let Some(prefix) = KERNEL_PREFIXES.iter().find(|prefix| {
name_for_match == **prefix || name_for_match.starts_with(&format!("{}-", prefix))
}) else {
let Some(prefix) = match_kernel_prefix(&name_for_match, LINUX_KERNEL_PREFIXES) else {
// Skip over anything that doesn't match a kernel prefix.
continue;
};
@@ -119,23 +114,23 @@ fn scan_directory(filesystem: &mut FileSystem, path: &str) -> Result<Vec<KernelP
// Acquire the suffix of the name, this will be used to match an initramfs.
let suffix = &name[prefix.len()..];
// Find a matching initramfs, if any.
let mut initramfs_prefix_iter = INITRAMFS_PREFIXES.iter();
// Find a matching initramfs by trying each candidate name, if any.
let mut candidates = initramfs_candidates(suffix, LINUX_INITRAMFS_PREFIXES);
let matched_initramfs_path = loop {
let Some(prefix) = initramfs_prefix_iter.next() else {
let Some(candidate) = candidates.next() else {
break None;
};
// Construct an initramfs path.
let initramfs = format!("{}{}", prefix, suffix);
let initramfs = CString16::try_from(initramfs.as_str())
let initramfs = CString16::try_from(candidate.as_str())
.context("unable to convert initramfs name to CString16")?;
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);
}
@@ -165,20 +160,22 @@ pub fn scan(
// Convert the device path root to a string we can use in the configuration.
let mut root = root
.to_string(DisplayOnly(false), AllowShortcuts(false))
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert device root to string")?
.to_string();
// Add a trailing forward-slash to the root to ensure the device root is completed.
root.push('/');
// Generate a unique hash of the root path.
let root_unique_hash = utils::unique_hash(&root);
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.
@@ -186,17 +183,15 @@ pub fn scan(
return Ok(false);
}
// Sort the kernel pairs by kernel version, if it has one, newer kernels first.
pairs.sort_by(|a, b| vercmp::compare_versions(&a.kernel, &b.kernel).reverse());
// Generate a unique name for the linux chainload action.
let chainload_action_name = format!("{}{}", LINUX_CHAINLOAD_ACTION_PREFIX, root_unique_hash,);
let chainload_action_name = format!("{}{}", LINUX_CHAINLOAD_ACTION_PREFIX, root_unique_hash);
// Kernel pairs are detected, generate a list configuration for it.
let generator = ListConfiguration {
entry: EntryDeclaration {
title: "Boot Linux $name".to_string(),
actions: vec![chainload_action_name.clone()],
sort_key: Some("$kernel".to_string()),
..Default::default()
},
values: pairs
@@ -241,6 +236,8 @@ pub fn scan(
path: "$kernel".to_string(),
options: vec!["$linux-options".to_string()],
linux_initrd: Some("$initrd".to_string()),
linux_initrd_chain: vec![],
devicetree: None,
};
// Insert the chainload action into the configuration.
+5 -4
View File
@@ -1,4 +1,3 @@
use crate::utils;
use alloc::string::ToString;
use alloc::{format, vec};
use anyhow::{Context, Result};
@@ -6,6 +5,7 @@ use edera_sprout_config::RootConfiguration;
use edera_sprout_config::actions::ActionDeclaration;
use edera_sprout_config::actions::chainload::ChainloadConfiguration;
use edera_sprout_config::entries::EntryDeclaration;
use edera_sprout_parsing::unique_hash;
use uefi::CString16;
use uefi::fs::{FileSystem, Path};
use uefi::proto::device_path::DevicePath;
@@ -38,26 +38,27 @@ pub fn scan(
// Convert the device path root to a string we can use in the configuration.
let mut root = root
.to_string(DisplayOnly(false), AllowShortcuts(false))
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert device root to string")?
.to_string();
// Add a trailing forward-slash to the root to ensure the device root is completed.
root.push('/');
// Generate a unique hash of the root path.
let root_unique_hash = utils::unique_hash(&root);
let root_unique_hash = unique_hash(&root);
// Generate a unique name for the Windows chainload action.
let chainload_action_name = format!("{}{}", WINDOWS_CHAINLOAD_ACTION_PREFIX, root_unique_hash,);
// Generate an entry name for Windows.
let entry_name = format!("auto-windows-{}", root_unique_hash,);
let entry_name = format!("auto-windows-{}", root_unique_hash);
// Create an entry for Windows and insert it into the configuration.
let entry = EntryDeclaration {
title: "Boot Windows".to_string(),
actions: vec![chainload_action_name.clone()],
values: Default::default(),
sort_key: None, // Use the default sort key.
};
config.entries.insert(entry_name, entry);
+104
View File
@@ -0,0 +1,104 @@
use alloc::{format, string::String};
use anyhow::{Context, Result, anyhow, bail};
use edera_sprout_bls::BootCounter;
use uefi::{
CString16, Handle,
data_types::Align,
fs::PathBuf,
mem::AlignedBuffer,
proto::media::file::{File, FileAttribute, FileInfo, FileMode},
proto::media::fs::SimpleFileSystem,
};
/// The size of the fixed part of a file info: three sizes, three timestamps and the attributes.
const FILE_INFO_HEADER_SIZE: usize = 3 * 8 + 3 * 16 + 8;
/// Where the boot counter of an entry is stored, so that a try can be consumed when the
/// entry is booted.
#[derive(Clone)]
pub struct BootCounterTarget {
/// The counter as it is currently encoded in the file name.
pub counter: BootCounter,
/// The handle of the filesystem that holds the entry file.
pub filesystem: Handle,
/// The directory that holds the entry file.
pub directory: PathBuf,
/// The entry id, which is the file name without the counter and extension.
pub id: String,
/// The current file name, including the counter and the extension.
pub file_name: String,
/// The extension of the file name, such as `.conf`, in its original case.
pub extension: String,
/// Whether booting the entry uses up a try. An entry whose tries are not used up still
/// has them, so it can be bad.
pub counting: bool,
}
impl BootCounterTarget {
/// Consumes one try by renaming the entry file, such as `foo+3.conf` to `foo+2-1.conf`.
/// The file is renamed in place without copying it, as unified kernel images are large and
/// a copy interrupted by a crash would leave a truncated image behind.
/// Returns the path of the renamed file.
pub fn consume(&self) -> Result<PathBuf> {
let new_name = format!(
"{}{}",
self.counter.decremented().render(&self.id),
self.extension
);
let new_name_16 =
CString16::try_from(new_name.as_str()).context("invalid new entry file name")?;
let mut fs = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(self.filesystem)
.context("unable to open the entry filesystem")?;
let mut root = fs
.open_volume()
.context("unable to open the entry volume")?;
// Open the entry file for writing.
let mut from = self.directory.clone();
from.push(PathBuf::from(
CString16::try_from(self.file_name.as_str()).context("invalid entry file name")?,
));
let from_name =
CString16::try_from(format!("{}", from).as_str()).context("invalid entry file path")?;
let handle = root
.open(&from_name, FileMode::ReadWrite, FileAttribute::empty())
.context("unable to open the entry file")?;
let mut file = handle
.into_regular_file()
.ok_or_else(|| anyhow!("the entry is not a regular file"))?;
// Renaming a file is done by setting its file info with a different name.
let info = file
.get_boxed_info::<FileInfo>()
.context("unable to get the entry file info")?;
// A read-only file is left as it is, as the counter is not meant to be changed.
if info.attribute().contains(FileAttribute::READ_ONLY) {
bail!("the entry file is read-only");
}
// The info is a fixed header followed by the null-terminated UCS-2 name.
let size =
FILE_INFO_HEADER_SIZE + 2 * (new_name.len() + 1) + <FileInfo as Align>::alignment();
let mut storage = AlignedBuffer::from_size_align(size, <FileInfo as Align>::alignment())
.context("unable to allocate the entry file info")?;
let renamed = FileInfo::new(
storage.as_slice_mut(),
info.file_size(),
info.physical_size(),
*info.create_time(),
*info.last_access_time(),
*info.modification_time(),
info.attribute(),
&new_name_16,
)
.map_err(|error| anyhow!("unable to build the renamed entry file info: {:?}", error))?;
file.set_info(renamed)
.context("unable to rename the entry file")?;
file.flush()
.context("unable to flush the renamed entry file")?;
let mut to = self.directory.clone();
to.push(PathBuf::from(new_name_16));
Ok(to)
}
}
+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)
+33 -45
View File
@@ -1,20 +1,24 @@
use crate::options::SproutOptions;
use alloc::boxed::Box;
use alloc::collections::{BTreeMap, BTreeSet};
use alloc::format;
use alloc::rc::Rc;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use anyhow::anyhow;
use anyhow::{Result, bail};
use core::cmp::Reverse;
use edera_sprout_config::actions::ActionDeclaration;
use edera_sprout_parsing::stamp_values;
use eficore::platform::timer::PlatformTimer;
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 {
@@ -197,11 +201,15 @@ impl SproutContext {
let mut did_change = false;
let mut values = BTreeMap::new();
for (key, value) in &current_values {
let (changed, result) = Self::stamp_values(&current_values, value);
let (changed, result) = stamp_values(&current_values, value);
if changed {
// 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);
}
@@ -221,52 +229,32 @@ impl SproutContext {
})
}
/// Stamps the `text` value with the specified `values` map. The returned value indicates
/// whether the `text` has been changed and the value that was stamped and changed.
///
/// Stamping works like this:
/// - Start with the input text.
/// - Sort all the keys in reverse length order (longest keys first)
/// - For each key, if the key is not empty, replace $KEY in the text.
/// - Each follow-up iteration acts upon the last iterations result.
/// - We keep track if the text changes during the replacement.
/// - We return both whether the text changed during any iteration and the final result.
fn stamp_values(values: &BTreeMap<String, String>, text: impl AsRef<str>) -> (bool, String) {
let mut result = text.as_ref().to_string();
let mut did_change = false;
// Sort the keys by length. This is to ensure that we stamp the longest keys first.
// If we did not do this, "$abc" could be stamped by "$a" into an invalid result.
let mut keys = values.keys().collect::<Vec<_>>();
// Sort by key length, reversed. This results in the longest keys appearing first.
keys.sort_by_key(|key| Reverse(key.len()));
for key in keys {
// Empty keys are not supported.
if key.is_empty() {
continue;
}
// We can fetch the value from the map. It is verifiable that the key exists.
let Some(value) = values.get(key) else {
unreachable!("keys iterated over is collected on a map that cannot be modified");
};
let next_result = result.replace(&format!("${key}"), value);
if result != next_result {
did_change = true;
}
result = next_result;
}
(did_change, result)
}
/// Stamps the input `text` with all the values in this [SproutContext] and it's parents.
/// For example, if this context contains {"a":"b"}, and the text "hello\\$a", it will produce
/// "hello\\b" as an output string.
pub fn stamp(&self, text: impl AsRef<str>) -> String {
Self::stamp_values(&self.all_values(), text.as_ref()).1
// A value can refer to other values, which a single pass leaves unresolved when the
// key it refers to was already handled. Stamp until the text settles, up to the same
// limit that finalizing a context uses.
let values = self.all_values();
let mut result = text.as_ref().to_string();
for _ in 0..CONTEXT_FINALIZE_ITERATION_LIMIT {
let (changed, stamped) = stamp_values(&values, &result);
result = stamped;
if !changed || result.len() > CONTEXT_FINALIZE_VALUE_LENGTH_LIMIT {
break;
}
}
result
}
/// Stamps all the items from the iterator `input` with all the values in this [SproutContext]
/// and it's parents. This calls [self.stamp] on each item.
pub fn stamp_iter(
&self,
input: impl Iterator<Item = impl AsRef<str>>,
) -> impl Iterator<Item = String> {
input.map(|item| self.stamp(item))
}
/// Unloads a [SproutContext] back into an owned context. This
+54 -16
View File
@@ -3,12 +3,16 @@ use alloc::collections::BTreeMap;
use alloc::format;
use alloc::rc::Rc;
use alloc::string::String;
use alloc::vec::Vec;
use anyhow::{Context, Result};
use edera_sprout_config::drivers::DriverDeclaration;
use edera_sprout_config::drivers::{DriverDeclaration, DriverProvides};
use eficore::loader::source::ImageSource;
use eficore::loader::{ImageLoadRequest, ImageLoader};
use log::info;
use uefi::boot::SearchType;
use uefi::proto::media::block::BlockIO;
use uefi::proto::media::disk::{DiskIo, DiskIo2};
use uefi::{Handle, Identify};
/// Loads the driver specified by the `driver` declaration.
fn load_driver(context: Rc<SproutContext>, driver: &DriverDeclaration) -> Result<()> {
@@ -18,7 +22,7 @@ fn load_driver(context: Rc<SproutContext>, driver: &DriverDeclaration) -> Result
// Resolve the path to the driver image.
let resolved = eficore::path::resolve_path(
Some(context.root().loaded_image_path()?),
&context.stamp(&driver.path),
context.stamp(&driver.path),
)
.context("unable to resolve path to driver")?;
@@ -37,27 +41,53 @@ fn load_driver(context: Rc<SproutContext>, driver: &DriverDeclaration) -> Result
Ok(())
}
/// Reconnects all handles to their controllers.
/// This is effectively a UEFI stack reload in a sense.
/// After we load all the drivers, we need to reconnect all of their handles
/// so that filesystems are recognized again.
fn reconnect() -> Result<()> {
// Locate all of the handles in the UEFI stack.
let handles = uefi::boot::locate_handle_buffer(SearchType::AllHandles)
.context("unable to locate handles buffer")?;
/// Locates the handles that are disks or partitions.
/// These are the only controllers that filesystem drivers can bind to.
fn locate_storage_handles() -> Result<Vec<Handle>> {
let mut storage = Vec::new();
for guid in [&BlockIO::GUID, &DiskIo::GUID, &DiskIo2::GUID] {
// Not finding any handles for a protocol is not an error.
let Ok(handles) = uefi::boot::locate_handle_buffer(SearchType::ByProtocol(guid)) else {
continue;
};
for handle in handles.iter() {
if !storage.contains(handle) {
storage.push(*handle);
}
}
}
Ok(storage)
}
for handle in handles.iter() {
/// Reconnects handles to their controllers.
/// This is effectively a UEFI stack reload in a sense.
/// After we load all the drivers, we need to reconnect their handles
/// so that filesystems are recognized again.
/// When `provides` is [`DriverProvides::Filesystems`], only disk and partition handles are reconnected.
fn reconnect(provides: DriverProvides) -> Result<()> {
// Locate the handles to reconnect in the UEFI stack.
let handles = match provides {
DriverProvides::Any => uefi::boot::locate_handle_buffer(SearchType::AllHandles)
.context("unable to locate handles buffer")?
.iter()
.copied()
.collect(),
DriverProvides::Filesystems => locate_storage_handles()?,
};
for handle in handles {
// Ignore the result as there is nothing we can do if reconnecting a controller fails.
// This is also likely to fail in some cases but should fail safely.
let _ = uefi::boot::connect_controller(*handle, None, None, true);
let _ = uefi::boot::connect_controller(handle, &[], None, true);
}
Ok(())
}
/// Load all the drivers specified in `drivers`.
/// There is no driver order currently. This will reconnect all the controllers
/// to all handles if at least one driver was loaded.
/// There is no driver order currently. This will reconnect the controllers
/// if at least one driver was loaded. Every controller is reconnected unless all
/// the drivers only provide filesystems, in which case only storage is reconnected.
pub fn load(
context: Rc<SproutContext>,
drivers: &BTreeMap<String, DriverDeclaration>,
@@ -74,8 +104,16 @@ pub fn load(
load_driver(context.clone(), driver).context(format!("unable to load driver: {}", name))?;
}
// Reconnect all the controllers to all handles.
reconnect().context("unable to reconnect drivers")?;
// Reconnect everything if any driver can provide anything, otherwise only storage.
let provides = if drivers
.values()
.all(|driver| driver.provides == DriverProvides::Filesystems)
{
DriverProvides::Filesystems
} else {
DriverProvides::Any
};
reconnect(provides).context("unable to reconnect drivers")?;
info!("loaded drivers");
// We've now loaded all the drivers, so we can return.
+139 -1
View File
@@ -1,7 +1,11 @@
use crate::boot_counter::BootCounterTarget;
use crate::context::SproutContext;
use alloc::format;
use alloc::rc::Rc;
use alloc::string::{String, ToString};
use edera_sprout_bls::profile_entry_id;
use edera_sprout_config::entries::EntryDeclaration;
use edera_sprout_parsing::{fnmatch_ignore_case, glob_match};
/// Represents an entry that is stamped and ready to be booted.
#[derive(Clone)]
@@ -12,6 +16,11 @@ pub struct BootableEntry {
declaration: EntryDeclaration,
default: bool,
pin_name: bool,
sort_key: Option<String>,
boot_counter: Option<BootCounterTarget>,
id_suffix: Option<String>,
id_profile: Option<String>,
extra_profile: bool,
}
impl BootableEntry {
@@ -29,6 +38,11 @@ impl BootableEntry {
declaration,
default: false,
pin_name: false,
sort_key: None,
boot_counter: None,
id_suffix: None,
id_profile: None,
extra_profile: false,
}
}
@@ -37,6 +51,53 @@ impl BootableEntry {
&self.name
}
/// Fetch the id of the entry, which is how the bootloader interface refers to it.
/// A BLS entry has the id systemd-boot gives it: the name with the file extension, such as
/// `fedora.conf`, in lower case. Any other entry has its name as the id.
pub fn id(&self) -> String {
match self.id_suffix {
Some(ref suffix) => {
let file = format!("{}{}", self.sort_name(), suffix);
profile_entry_id(&file, self.id_profile.as_deref())
}
None => self.name.clone(),
}
}
/// Fetch the name of the entry without the profile of a unified kernel image, which is
/// the same for every profile of the same image.
pub fn sort_name(&self) -> &str {
self.id_profile
.as_deref()
.and_then(|profile| {
self.name
.strip_suffix(profile)
.and_then(|name| name.strip_suffix('@'))
})
.unwrap_or(&self.name)
}
/// Fetch whether the entry is a profile of a unified kernel image after the first, which
/// is never picked as the default entry unless it was asked for.
pub fn is_extra_profile(&self) -> bool {
self.id_profile.is_some() || self.extra_profile
}
/// Mark this entry as booting a profile of a unified kernel image after the first.
pub fn mark_extra_profile(&mut self) {
self.extra_profile = true;
}
/// Set the profile of a unified kernel image that is part of the id of this entry.
pub fn set_id_profile(&mut self, profile: &str) {
self.id_profile = Some(profile.to_string());
}
/// Set the file extension that is part of the id of this entry, such as `.conf`.
pub fn set_id_suffix(&mut self, suffix: &str) {
self.id_suffix = Some(suffix.to_string());
}
/// Fetch the title of the entry. This is usually a human-readable key.
pub fn title(&self) -> &str {
&self.title
@@ -72,6 +133,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;
@@ -87,14 +158,81 @@ impl BootableEntry {
self.pin_name = true;
}
/// Record where the boot counter of this entry is stored.
pub fn set_boot_counter(&mut self, target: BootCounterTarget) {
self.boot_counter = Some(target);
}
/// Fetch where the boot counter of this entry is stored, if it has one.
pub fn boot_counter(&self) -> Option<&BootCounterTarget> {
self.boot_counter.as_ref()
}
/// Fetch whether the entry has a boot counter with no tries left.
pub fn is_bad(&self) -> bool {
self.boot_counter
.as_ref()
.is_some_and(|target| target.counter.is_bad())
}
/// Prepend the name of the entry with `prefix`.
pub fn prepend_name_prefix(&mut self, prefix: &str) {
self.name.insert_str(0, prefix);
}
/// Determine if this entry matches `needle` by comparing to the name or title of the entry.
/// The `needle` is a glob pattern, where `*` matches any sequence of characters.
/// A `needle` without any `*` must equal the name or title exactly.
pub fn is_match(&self, needle: &str) -> bool {
self.name == needle || self.title == needle
glob_match(needle, &self.name) || glob_match(needle, &self.title)
}
/// Determine if this entry matches `needle` by comparing to the id or the name of the entry,
/// so that both `fedora.conf` and `fedora` select the entry. This is how loader.conf and the
/// bootloader interface select entries, so the `needle` is a pattern that is matched ignoring
/// case and supports `*`, `?` and `[a-z]`.
pub fn is_match_id(&self, needle: &str) -> bool {
fnmatch_ignore_case(needle, &self.id()) || fnmatch_ignore_case(needle, &self.name)
}
/// Create a variant of this entry, with the name suffixed by `suffix` and using `context`.
/// The actions of the entry are stamped with `context` when the entry is booted.
/// Variants of the same entry keep the sort key of this entry, so they stay grouped
/// together in the order they were created in.
pub fn variant(&self, suffix: &str, context: Rc<SproutContext>) -> Self {
let mut entry = self.clone();
entry.name.push_str(suffix);
// Without any sort key, the name is used to sort, which differs between variants.
// Pin the sort key to the name of this entry to keep the variants together.
if entry.sort_key.is_none() && entry.declaration.sort_key.is_none() {
entry.sort_key = Some(self.name.clone());
}
entry.context = context;
entry
}
/// Set the sort key of the entry. This is used to sort entries via version comparison.
pub fn set_sort_key(&mut self, sort_key: String) {
self.sort_key = Some(sort_key);
}
/// Retrieve a reference to the sort key of the entry. If one is not specified, we will use the
/// name of the entry.
pub fn sort_key(&self) -> &str {
// Use the sort key specified in the bootable entry, or use the declaration sort key,
// or use the name of the entry.
self.sort_key
.as_deref()
.or(self.declaration.sort_key.as_deref())
.unwrap_or(&self.name)
}
/// Find the first entry that matches `needle` by id inside the entry iterator `haystack`.
pub fn find_id<'a>(
needle: &str,
haystack: impl Iterator<Item = &'a BootableEntry>,
) -> Option<&'a BootableEntry> {
haystack.into_iter().find(|entry| entry.is_match_id(needle))
}
/// Find an entry by `needle` inside the entry iterator `haystack`.
@@ -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.
+95 -4
View File
@@ -1,10 +1,14 @@
use crate::context::SproutContext;
use crate::entries::BootableEntry;
use alloc::collections::BTreeSet;
use alloc::format;
use alloc::rc::Rc;
use alloc::string::String;
use alloc::vec::Vec;
use anyhow::Result;
use anyhow::bail;
use edera_sprout_config::generators::GeneratorDeclaration;
use edera_sprout_parsing::{build_variants, rule_matches};
/// The BLS generator.
pub mod bls;
@@ -15,10 +19,8 @@ pub mod list;
/// The matrix generator.
pub mod matrix;
/// Runs the generator specified by the `generator` option.
/// It uses the specified `context` as the parent context for
/// the generated entries, injecting more values if needed.
pub fn generate(
/// Runs the generator kind specified by the `generator` option.
fn generate_base(
context: Rc<SproutContext>,
generator: &GeneratorDeclaration,
) -> Result<Vec<BootableEntry>> {
@@ -32,3 +34,92 @@ pub fn generate(
bail!("unknown generator configuration");
}
}
/// Multiplies `entries` by every combination of the variants in `generator`.
fn apply_variants(
entries: Vec<BootableEntry>,
generator: &GeneratorDeclaration,
) -> Result<Vec<BootableEntry>> {
// Without any variants, the entries are used as-is.
if generator.variants.is_empty() {
return Ok(entries);
}
// Validate the axes, as an axis without choices would silently produce no entries,
// and duplicate choice names would produce entries with the same name.
let mut axes = Vec::new();
for (axis, choices) in &generator.variants {
if choices.is_empty() {
bail!("variant axis '{}' has no choices", axis);
}
let mut seen = BTreeSet::new();
for choice in choices {
if !seen.insert(choice.name.as_str()) {
bail!(
"variant axis '{}' has more than one choice named '{}'",
axis,
choice.name
);
}
}
axes.push((
axis.clone(),
choices
.iter()
.map(|choice| (choice.name.clone(), choice.values.clone()))
.collect(),
));
}
let combinations = build_variants(&axes);
let mut result = Vec::with_capacity(entries.len() * combinations.len());
for entry in &entries {
for combination in &combinations {
let mut context = entry.context().fork();
context.insert(&combination.values);
let suffix: String = combination
.names
.iter()
.map(|name| format!("-{}", name))
.collect();
result.push(entry.variant(&suffix, context.freeze()));
}
}
Ok(result)
}
/// Runs the generator specified by the `generator` option, named `name`.
/// It uses the specified `context` as the parent context for
/// the generated entries, injecting more values if needed.
/// Entry names are prefixed with the generator name unless pinned,
/// then variants are applied, and then excluded entries are removed.
pub fn generate(
name: &str,
context: Rc<SproutContext>,
generator: &GeneratorDeclaration,
) -> Result<Vec<BootableEntry>> {
let mut entries = generate_base(context, generator)?;
// We will prefix all entries with [name]-, provided the name is not pinned.
let prefix = format!("{}-", name);
for entry in &mut entries {
if !entry.is_pin_name() {
entry.prepend_name_prefix(&prefix);
}
}
let mut entries = apply_variants(entries, generator)?;
// Remove any entries that match an exclusion rule.
if !generator.exclude.is_empty() {
entries.retain(|entry| {
let values = entry.context().all_values();
!generator
.exclude
.iter()
.any(|rule| rule_matches(rule, &values))
});
}
Ok(entries)
}
+501 -123
View File
@@ -1,102 +1,160 @@
use crate::boot_counter::BootCounterTarget;
use crate::context::SproutContext;
use crate::entries::BootableEntry;
use crate::generators::bls::entry::BlsEntry;
use crate::utils::vercmp;
use alloc::format;
use alloc::rc::Rc;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use alloc::{
format,
rc::Rc,
string::{String, ToString},
vec::Vec,
};
use anyhow::{Context, Result};
use core::cmp::Ordering;
use core::str::FromStr;
use core::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use core::{cmp::Ordering, str::FromStr};
use edera_sprout_bls::{
BlsEntry, BootCounter, is_reserved_entry_name, profile_id_suffix, sort_bls, strip_extension,
};
use edera_sprout_config::generators::bls::BlsConfiguration;
use uefi::cstr16;
use uefi::fs::{FileSystem, PathBuf};
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
use uefi::proto::media::fs::SimpleFileSystem;
use log::{info, warn};
use uefi::{
CString16, cstr16,
fs::{FileSystem, PathBuf},
proto::device_path::text::{AllowShortcuts, DisplayOnly},
proto::media::fs::SimpleFileSystem,
};
/// BLS entry parser.
mod entry;
mod uki;
/// The PE machine type of the images that can boot on this machine.
#[cfg(target_arch = "x86_64")]
const PE_MACHINE: u16 = edera_sprout_bls::PE_MACHINE_X86_64;
/// The PE machine type of the images that can boot on this machine.
#[cfg(target_arch = "aarch64")]
const PE_MACHINE: u16 = edera_sprout_bls::PE_MACHINE_AARCH64;
/// The name of the architecture of this machine in BLS entries.
#[cfg(target_arch = "x86_64")]
const ARCHITECTURE: &str = "x64";
/// The name of the architecture of this machine in BLS entries.
#[cfg(target_arch = "aarch64")]
const ARCHITECTURE: &str = "aa64";
/// Whether the extended boot loader partition was added by a generator in strict mode, which
/// does it once, so that more than one generator does not list its entries twice.
static STRICT_XBOOTLDR_ADDED: AtomicBool = AtomicBool::new(false);
/// The number of initrd slots set on each BLS entry.
/// Entries set `initrd-0` through `initrd-31`, and slots without an initrd are empty.
pub const BLS_INITRD_SLOTS: usize = 32;
// 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.
/// Reference: https://uapi-group.org/specifications/specs/boot_loader_specification/#sorting
/// Reference: <https://uapi-group.org/specifications/specs/boot_loader_specification/#sorting>
fn sort_entries(a: &(BlsEntry, BootableEntry), b: &(BlsEntry, BootableEntry)) -> Ordering {
// Grab the components of both entries.
let (a_bls, a_boot) = a;
let (b_bls, b_boot) = b;
// Grab the sort keys from both entries.
let a_sort_key = a_bls.sort_key();
let b_sort_key = b_bls.sort_key();
// Compare the sort keys of both entries.
match a_sort_key.cmp(&b_sort_key) {
// If A and B sort keys are equal, sort by machine-id.
Ordering::Equal => {
// Grab the machine-id from both entries.
let a_machine_id = a_bls.machine_id();
let b_machine_id = b_bls.machine_id();
// Compare the machine-id of both entries.
match a_machine_id.cmp(&b_machine_id) {
// If both machine-id values are equal, sort by version.
Ordering::Equal => {
// Grab the version from both entries.
let a_version = a_bls.version();
let b_version = b_bls.version();
// Compare the version of both entries, sorting newer versions first.
match vercmp::compare_versions_optional(
a_version.as_deref(),
b_version.as_deref(),
)
.reverse()
{
// If both versions are equal, sort by file name in reverse order.
Ordering::Equal => {
// Grab the file name from both entries.
let a_name = a_boot.name();
let b_name = b_boot.name();
// Compare the file names of both entries, sorting newer entries first.
vercmp::compare_versions(a_name, b_name).reverse()
}
other => other,
}
}
other => other,
}
}
other => other,
}
sort_bls(a_bls, a_boot.sort_name(), b_bls, b_boot.sort_name())
}
/// Generates entries from the BLS entries directory using the specified `bls` configuration and
/// `context`. The BLS conversion is best-effort and will ignore any unsupported entries.
pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Vec<BootableEntry>> {
/// Produces the bootable entry for the BLS `entry` with the id `name` and the `initrds`.
fn bootable_entry(
context: &Rc<SproutContext>,
bls: &BlsConfiguration,
name: &str,
entry: &BlsEntry,
initrds: &[String],
entry_root: &str,
) -> BootableEntry {
// Produce a new sprout context for the entry with the extracted values.
let mut context = context.fork();
let title_base = entry.title().unwrap_or_else(|| name.to_string());
let chainload = entry.chainload_path().unwrap_or_default();
let options = entry.chainload_options().unwrap_or_default();
let version = entry.version().unwrap_or_default();
let machine_id = entry.machine_id().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) {
format!("{} {}", title_base, version)
} else {
title_base.clone()
};
// The device that the files of the entry are on, for a path like "$entry-root\\$chainload".
// It is empty for the partition that Sprout was loaded from.
context.set("entry-root", entry_root.to_string());
context.set("title-base", title_base);
context.set("title", title_full);
context.set("chainload", chainload);
context.set("options", options);
// The command line and kernel version embedded in a unified kernel image.
context.set("cmdline", entry.cmdline.clone().unwrap_or_default());
context.set("devicetree", entry.devicetree_path().unwrap_or_default());
context.set("uname", entry.uname.clone().unwrap_or_default());
// 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);
// Produce a new bootable entry.
let mut boot = BootableEntry::new(
name.to_string(),
bls.entry.title.clone(),
context.freeze(),
bls.entry.clone(),
);
// Pin the entry name to prevent prefixing.
// This is needed as the bootloader interface requires the name to be
// the same as the entry file name, minus the .conf extension.
if bls.pin_names {
boot.mark_pin_name();
}
boot
}
/// Generates the BLS Type #1 entries from the entries directory under `path`.
/// The conversion is best-effort and will ignore any unsupported entries.
fn generate_type1(
context: &Rc<SproutContext>,
bls: &BlsConfiguration,
path: &str,
entry_root: &str,
) -> Result<Vec<(BlsEntry, BootableEntry)>> {
let strict = context.root().options().bls_strict_mode;
let mut entries = Vec::new();
// Stamp the path to the BLS directory.
let path = context.stamp(&bls.path);
// Resolve the path to the BLS directory.
let bls_resolved =
eficore::path::resolve_path(Some(context.root().loaded_image_path()?), &path)
.context("unable to resolve bls path")?;
let bls_resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), path)
.context("unable to resolve bls path")?;
// Construct a filesystem path to the BLS entries directory.
let mut entries_path = PathBuf::from(
bls_resolved
.sub_path
.to_string(DisplayOnly(false), AllowShortcuts(false))
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert bls path to string")?,
);
entries_path.push(cstr16!("entries"));
@@ -107,6 +165,15 @@ pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Ve
.context("unable to open bls filesystem")?;
let mut fs = FileSystem::new(fs);
// The entries directory is optional, as a BLS directory may only contain a loader.conf file.
// If it does not exist, there are no entries to generate.
if !fs
.try_exists(&entries_path)
.context("unable to check for bls entries directory")?
{
return Ok(Vec::new());
}
// Read the BLS entries directory.
let entries_iter = fs
.read_dir(&entries_path)
@@ -123,89 +190,400 @@ pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Ve
}
// Get the file name of the filesystem item.
let mut name = entry.file_name().to_string();
let file_name = entry.file_name().to_string();
// Ignore files that are not .conf files.
if !name.to_lowercase().ends_with(".conf") {
// Ignore hidden files, such as the AppleDouble files that macOS writes, and files that
// are not .conf files. Files named just ".conf" are not valid entry files.
let Some((stem, extension)) = strip_extension(&file_name, ".conf") else {
continue;
};
if file_name.starts_with('.') || stem.is_empty() {
continue;
}
// Remove the .conf extension.
name.truncate(name.len() - 5);
// Skip over files that are named just ".conf" as they are not valid entry files.
if name.is_empty() {
// Names that start with auto- are reserved for entries the boot loader makes itself.
if is_reserved_entry_name(&file_name) {
continue;
}
let extension = extension.to_string();
// Split the boot counter off the file name. The entry id never includes the counter, so
// that it stays the same as tries are consumed.
let (id, boot_counter) = {
let (id, counter) = BootCounter::parse(stem);
(id.to_string(), counter)
};
let name = id;
// Create a mutable path so we can append the file name to produce the full path.
let mut full_entry_path = entries_path.to_path_buf();
full_entry_path.push(entry.file_name());
// Read the entry file.
let content = fs
.read(full_entry_path)
.context("unable to read bls file")?;
// Entries that can't be read or parsed are skipped, as one broken entry
// should not prevent booting any of the other entries.
let content = match fs.read(full_entry_path) {
Ok(content) => content,
Err(error) => {
warn!("unable to read bls entry {}: {}", name, error);
continue;
}
};
// Parse the entry file as a UTF-8 string.
let content = String::from_utf8(content).context("unable to read bls entry as utf8")?;
let content = match String::from_utf8(content) {
Ok(content) => content,
Err(error) => {
warn!("unable to read bls entry {} as utf8: {}", name, error);
continue;
}
};
// Parse the entry file as a BLS entry.
let entry = BlsEntry::from_str(&content).context("unable to parse bls entry")?;
let mut entry = match BlsEntry::from_str(&content) {
Ok(entry) => entry,
Err(error) => {
warn!("unable to parse bls entry {}: {}", name, error);
continue;
}
};
entry.boot_counter = boot_counter;
// Ignore entries that are not valid for Sprout.
if !entry.is_valid() {
continue;
}
// Produce a new sprout context for the entry with the extracted values.
let mut context = context.fork();
// Ignore entries that are for another architecture.
if !entry.matches_architecture(ARCHITECTURE) {
continue;
}
let title_base = entry.title().unwrap_or_else(|| name.clone());
let chainload = entry.chainload_path().unwrap_or_default();
let options = entry.options().unwrap_or_default();
let version = entry.version().unwrap_or_default();
let machine_id = entry.machine_id().unwrap_or_default();
// An entry that names more than one of linux, efi and uki is broken, and systemd-boot
// does not show it. Outside of strict mode, the first of them in that order is booted.
if entry.mixes_boot_targets() {
if strict {
warn!(
"hiding bls entry {} as it has more than one of linux, efi and uki",
name
);
continue;
}
warn!(
"bls entry {} has more than one of linux, efi and uki, which systemd-boot hides; \
using the first of linux, efi and uki (strict mode hides it)",
name
);
}
// Put the initrd through a quirk modifier to support Fedora.
let initrd = quirk_initrd_remove_tuned(entry.initrd_path().unwrap_or_default());
// The file that the entry boots has to exist. systemd-boot hides an entry that has
// none, while outside of strict mode it is shown and fails when it is booted.
if let Some(target) = entry.chainload_path()
&& let Ok(target_path) = CString16::try_from(format!("\\{}", target).as_str())
&& let Ok(false) = fs.try_exists(PathBuf::from(target_path))
{
if strict {
warn!(
"hiding bls entry {} as its file '\\{}' does not exist",
name, target
);
continue;
}
warn!(
"bls entry {} refers to the file '\\{}', which does not exist (strict mode hides it)",
name, target
);
}
// 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) {
format!("{} {}", title_base, version)
} else {
title_base.clone()
};
// Put the initrds through a quirk modifier to support Fedora.
let initrds = quirk_initrd_remove_tuned(entry.initrd_paths());
context.set("title-base", title_base);
context.set("title", title_full);
context.set("chainload", chainload);
context.set("options", options);
context.set("initrd", initrd);
context.set("version", version);
context.set("machine-id", machine_id);
// 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 bootable entry.
let mut boot = BootableEntry::new(
name,
bls.entry.title.clone(),
context.freeze(),
bls.entry.clone(),
);
let mut boot = bootable_entry(context, bls, &name, &entry, &initrds, entry_root);
boot.set_id_suffix(&extension);
if entry.profile_number() > 0 {
boot.mark_extra_profile();
}
// Pin the entry name to prevent prefixing.
// This is needed as the bootloader interface requires the name to be
// the same as the entry file name, minus the .conf extension.
boot.mark_pin_name();
// Record where the boot counter lives so a try can be consumed when this entry boots.
// systemd-boot only counts the tries of the entries it boots as a kernel or as a unified
// kernel image, and not those of a plain EFI program.
let counted = !(strict && entry.linux.is_none() && entry.uki.is_none());
if let Some(counter) = boot_counter {
boot.set_boot_counter(BootCounterTarget {
counter,
filesystem: bls_resolved.filesystem_handle,
directory: entries_path.clone(),
id: name.clone(),
file_name,
extension,
counting: counted,
});
}
// Add the BLS entry to the list, along with the bootable entry.
entries.push((entry, boot));
}
Ok(entries)
}
/// Generates the BLS Type #2 entries from the unified kernel images in the `uki_path` directory.
/// An image with the same id as one of the Type #1 `entries` is skipped.
fn generate_type2(
context: &Rc<SproutContext>,
bls: &BlsConfiguration,
uki_path: &str,
entry_root: &str,
entries: &[(BlsEntry, BootableEntry)],
) -> Result<Vec<(BlsEntry, BootableEntry)>> {
let resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), uki_path)
.context("unable to resolve uki path")?;
let directory = resolved
.sub_path
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert uki path to string")?;
let directory_name = directory.to_string();
let mut found: Vec<(BlsEntry, BootableEntry)> = Vec::new();
let strict = context.root().options().bls_strict_mode;
for image in uki::scan(resolved.filesystem_handle, &directory_name, strict)? {
// Only unified kernel images that are built for this machine are entries.
if image.machine != PE_MACHINE {
info!(
"skipping {} as it is built for another architecture",
image.file_name
);
continue;
}
// The id is the file name without the extension and the boot counter.
let file_name = image.file_name;
if is_reserved_entry_name(&file_name) {
continue;
}
let Some((stem, extension)) = strip_extension(&file_name, ".efi") else {
continue;
};
let extension = extension.to_string();
let (id, boot_counter) = BootCounter::parse(stem);
if id.is_empty() {
continue;
}
let id = id.to_string();
// Every profile of the image is an entry. They share the file, and so the boot counter.
let mut suffixes: Vec<String> = Vec::new();
for profile in image.profiles {
// A profile without an os-release section is not a unified kernel image, such as
// other EFI programs.
if !profile.has_osrel {
info!(
"skipping {} as it has no .osrel section, so it is not a unified kernel image",
file_name
);
continue;
}
// The first profile has the id of the file, and the others name their profile.
// A profile that repeats the identifier of an earlier one is told apart by its number.
let index = profile.index.unwrap_or(0);
let mut suffix = profile_id_suffix(index, &profile.info);
if let Some(ref name) = suffix
&& suffixes.contains(name)
{
suffix = Some(index.to_string());
}
if let Some(ref name) = suffix {
suffixes.push(name.clone());
}
let name = match suffix {
Some(ref suffix) => format!("{}@{}", id, suffix),
None => id.clone(),
};
// systemd-boot skips an image that has no name, while outside of strict mode the
// name of the file is used.
if strict && profile.entry.title.is_none() {
warn!(
"hiding unified kernel image {} as it has no name in its os-release",
file_name
);
continue;
}
let mut entry = profile.entry;
entry.boot_counter = boot_counter;
let mut boot = bootable_entry(context, bls, &name, &entry, &[], entry_root);
boot.set_id_suffix(&extension);
if let Some(ref suffix) = suffix {
boot.set_id_profile(suffix);
}
// An image with the same id as another entry, such as a leftover copy with another
// boot counter, would be ambiguous. Type 1 and type 2 entries have different ids.
if entries
.iter()
.chain(found.iter())
.any(|(_, other)| other.id() == boot.id())
{
warn!(
"unified kernel image {} has the same id as another entry, skipping",
file_name
);
continue;
}
if let Some(counter) = boot_counter {
boot.set_boot_counter(BootCounterTarget {
counter,
filesystem: resolved.filesystem_handle,
directory: PathBuf::from(directory.clone()),
id: id.clone(),
file_name: file_name.clone(),
extension: extension.clone(),
counting: true,
});
}
found.push((entry, boot));
}
}
Ok(found)
}
/// Generates the entries of one partition, from the BLS entries directory at `bls_path` and the
/// unified kernel image directory at `uki_path`, if there is one. The files of the entries are on
/// the device `entry_root`.
fn generate_partition(
context: &Rc<SproutContext>,
bls: &BlsConfiguration,
bls_path: &str,
uki_path: Option<&str>,
entry_root: &str,
) -> Result<Vec<(BlsEntry, BootableEntry)>> {
// A problem reading the Type #1 entries only stops the unified kernel images when there
// are none to add, so that either kind of entry can still boot.
let mut entries = match generate_type1(context, bls, bls_path, entry_root) {
Ok(entries) => entries,
Err(error) if uki_path.is_some() => {
warn!("unable to generate bls entries: {:#}", error);
Vec::new()
}
Err(error) => return Err(error),
};
// Add the unified kernel images, unless that is disabled. A problem scanning them
// should not prevent booting the other entries.
if let Some(uki_path) = uki_path {
let uki_path = context.stamp(uki_path);
match generate_type2(context, bls, &uki_path, entry_root, &entries) {
Ok(found) => entries.extend(found),
Err(error) => warn!(
"unable to generate unified kernel image entries: {:#}",
error
),
}
}
Ok(entries)
}
/// Finds the root of the Extended Boot Loader Partition on the disk that Sprout was loaded from,
/// as the text of the device with a trailing slash. None if there is no such partition.
fn xbootldr_root(context: &Rc<SproutContext>) -> Result<Option<String>> {
let Some(path) = eficore::xbootldr::find_xbootldr(context.root().loaded_image_path()?)? else {
return Ok(None);
};
let mut root = path
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert the xbootldr device path to a string")?
.to_string();
// Add a trailing forward-slash to the root to ensure the device root is completed.
root.push('/');
Ok(Some(root))
}
/// Generates entries from the BLS entries directory and the unified kernel image directory
/// using the specified `bls` configuration and `context`, and from the Extended Boot Loader
/// Partition if that is enabled. The BLS conversion is best-effort and will ignore any
/// unsupported entries.
pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Vec<BootableEntry>> {
// Stamp the path to the BLS directory.
let path = context.stamp(&bls.path);
let uki_path = bls.uki_path_for(&path);
// A problem with this partition only stops the generator when there is no other partition.
let mut entries = match generate_partition(
&context,
bls,
&path,
uki_path.as_deref(),
BlsConfiguration::root_of(&path),
) {
Ok(entries) => entries,
Err(error) if bls.xbootldr || context.root().options().bls_strict_mode => {
warn!("unable to generate bls entries: {:#}", error);
Vec::new()
}
Err(error) => return Err(error),
};
// Add the entries of the Extended Boot Loader Partition, which are sorted together with the
// others. A problem with it should not prevent booting from the entries that are found.
let strict_xbootldr = context.root().options().bls_strict_mode
&& !STRICT_XBOOTLDR_ADDED.swap(true, AtomicOrdering::Relaxed);
if bls.xbootldr || strict_xbootldr {
match xbootldr_root(&context) {
Ok(Some(root)) => {
let xbootldr_uki = uki_path.as_ref().map(|_| format!("{}\\EFI\\Linux", root));
match generate_partition(
&context,
bls,
&format!("{}\\loader", root),
xbootldr_uki.as_deref(),
&root,
) {
Ok(found) => {
for (entry, boot) in found {
// An entry that is on both partitions would be ambiguous.
if entries.iter().any(|(_, other)| other.id() == boot.id()) {
warn!(
"xbootldr entry {} has the same id as another entry, skipping",
boot.id()
);
} else {
entries.push((entry, boot));
}
}
}
Err(error) => warn!("unable to generate xbootldr entries: {:#}", error),
}
}
Ok(None) => info!("no xbootldr partition was found"),
Err(error) => warn!("unable to find the xbootldr partition: {:#}", error),
}
}
// Sort all the entries according to the BLS sort system.
entries.sort_by(sort_entries);
// Grab the number of entries that we have, so we can calculate a reverse index.
let entry_count = entries.len();
// Set the sort keys of all the bootable entries to a semi-unique prefix + the BLS sort order.
// The final comparison happens using version comparison, so this will sort
// things properly.
for (idx, (_bls, boot)) in entries.iter_mut().enumerate() {
boot.set_sort_key(format!("bls-{}-{}", path, entry_count - idx - 1));
}
// Collect all the bootable entries and return them.
Ok(entries.into_iter().map(|(_, boot)| boot).collect())
}
-168
View File
@@ -1,168 +0,0 @@
use alloc::string::{String, ToString};
use anyhow::{Error, Result};
use core::str::FromStr;
/// Represents a parsed BLS entry.
/// Fields unrelated to Sprout are not included.
#[derive(Default, Debug, Clone)]
pub struct BlsEntry {
/// The title of the entry.
pub title: Option<String>,
/// The options to pass to the entry.
pub options: Option<String>,
/// The path to the linux kernel.
pub linux: Option<String>,
/// The path to the initrd.
pub initrd: Option<String>,
/// The path to an EFI image.
pub efi: Option<String>,
/// The sort key for the entry.
pub sort_key: Option<String>,
/// The version of the entry.
pub version: Option<String>,
/// The machine id of the entry.
pub machine_id: Option<String>,
}
/// Parser for a BLS entry.
impl FromStr for BlsEntry {
type Err = Error;
/// Parses the `input` as a BLS entry file.
fn from_str(input: &str) -> Result<Self> {
// All the fields in a BLS entry we understand.
// Set all to None initially.
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 efi: Option<String> = None;
let mut sort_key: Option<String> = None;
let mut version: Option<String> = None;
let mut machine_id: Option<String> = None;
// Iterate over each line in the input and parse it.
for line in input.lines() {
// Trim the line.
let line = line.trim();
// Skip over empty lines and comments.
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split the line once by whitespace. This technically includes newlines but since
// the lines iterator is used, there should never be a newline here.
let Some((key, value)) = line.split_once(char::is_whitespace) else {
continue;
};
// Match the key to a field we understand.
match key {
// The title of the entry.
"title" => {
title = Some(value.trim().to_string());
}
// The options to pass to the entry.
"options" => {
options = Some(value.trim().to_string());
}
// The path to the linux kernel.
"linux" => {
linux = Some(value.trim().to_string());
}
// The path to the initrd.
"initrd" => {
initrd = Some(value.trim().to_string());
}
// The path to an EFI image.
"efi" => {
efi = Some(value.trim().to_string());
}
"sort-key" => {
sort_key = Some(value.trim().to_string());
}
"version" => {
version = Some(value.trim().to_string());
}
"machine-id" => {
machine_id = Some(value.trim().to_string());
}
// Ignore any other key.
_ => {
continue;
}
}
}
// Produce a BLS entry from the parsed fields.
Ok(Self {
title,
options,
linux,
initrd,
efi,
sort_key,
version,
machine_id,
})
}
}
impl BlsEntry {
/// Checks if this BLS entry is something we can actually boot in Sprout.
pub fn is_valid(&self) -> bool {
self.linux.is_some() || self.efi.is_some()
}
/// Fetches the path to an EFI bootable image to boot, if any.
/// This prioritizes the linux field over efi.
/// It also converts / to \\ to match EFI path style.
pub fn chainload_path(&self) -> Option<String> {
self.linux
.clone()
.or(self.efi.clone())
.map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string())
}
/// Fetches the path to an initrd to pass to the kernel, if any.
/// It also converts / to \\ to match EFI path style.
pub fn initrd_path(&self) -> Option<String> {
self.initrd
.clone()
.map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string())
}
/// Fetches the options to pass to the kernel, if any.
pub fn options(&self) -> Option<String> {
self.options.clone()
}
/// Fetches the title of the entry, if any.
pub fn title(&self) -> Option<String> {
self.title.clone()
}
/// Fetches the sort key of the entry, if any.
pub fn sort_key(&self) -> Option<String> {
self.sort_key.clone()
}
/// Fetches the version of the entry, if any.
pub fn version(&self) -> Option<String> {
self.version.clone()
}
/// Fetches the machine id of the entry, if any.
pub fn machine_id(&self) -> Option<String> {
self.machine_id.clone()
}
}
+136
View File
@@ -0,0 +1,136 @@
use alloc::{
format,
string::{String, ToString},
vec::Vec,
};
use anyhow::{Context, Result, anyhow, bail};
use edera_sprout_bls::{
BlsEntry, PeImage, ProfileInfo, ReadAt, UKI_SECTIONS, read_pe, strip_extension, uki_profiles,
};
use log::warn;
use uefi::{
CString16, Handle, Status,
proto::media::file::{File, FileAttribute, FileInfo, FileMode, FileType, RegularFile},
proto::media::fs::SimpleFileSystem,
};
/// A unified kernel image that was found, with the metadata Sprout reads.
pub struct UkiFile {
/// The name of the file, such as `fedora+3.efi`.
pub file_name: String,
/// The machine type of the image.
pub machine: u16,
/// The profiles of the image. An image without profiles has one.
pub profiles: Vec<UkiProfileEntry>,
}
/// One profile of a unified kernel image.
pub struct UkiProfileEntry {
/// The number of the profile, or None if the image has no profiles.
pub index: Option<u32>,
/// The metadata of the profile.
pub info: ProfileInfo,
/// Whether the profile has an `.osrel` section, which unified kernel images always have.
pub has_osrel: bool,
/// The entry made from the sections of the profile.
pub entry: BlsEntry,
}
/// Reads a file at arbitrary offsets, so that a large image isn't loaded to read its headers.
struct FileReader(RegularFile);
impl ReadAt for FileReader {
fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> {
self.0
.set_position(offset)
.map_err(|error| anyhow!("unable to seek: {}", error))?;
let mut done = 0;
while done < buf.len() {
let read = self
.0
.read(&mut buf[done..])
.map_err(|error| anyhow!("unable to read: {}", error))?;
if read == 0 {
bail!("unexpected end of file");
}
done += read;
}
Ok(())
}
}
/// Finds the `.efi` files in `directory` on `filesystem` and reads their sections.
/// A missing directory has no images. Files that can't be read as a PE image are skipped,
/// as one broken file should not prevent booting any of the other entries.
pub fn scan(filesystem: Handle, directory: &str, strict: bool) -> Result<Vec<UkiFile>> {
let mut fs = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(filesystem)
.context("unable to open the unified kernel image filesystem")?;
let mut root = fs
.open_volume()
.context("unable to open the unified kernel image volume")?;
let name = CString16::try_from(directory).context("invalid unified kernel image path")?;
let handle = match root.open(&name, FileMode::Read, FileAttribute::empty()) {
Ok(handle) => handle,
Err(error) if error.status() == Status::NOT_FOUND => return Ok(Vec::new()),
Err(error) => return Err(error).context("unable to open the unified kernel image path"),
};
let FileType::Dir(mut directory_file) = handle
.into_type()
.context("unable to inspect the unified kernel image path")?
else {
bail!("the unified kernel image path is not a directory");
};
// Collect the file names first, as files can't be opened while the directory is read.
let mut names = Vec::new();
while let Some(info) = directory_file
.read_entry_boxed()
.context("unable to read the unified kernel image directory")?
{
let info: &FileInfo = &info;
if info.attribute().contains(FileAttribute::DIRECTORY) {
continue;
}
let file_name = info.file_name().to_string();
// Hidden files, such as the AppleDouble files that macOS writes, are not images.
if !file_name.starts_with('.') && strip_extension(&file_name, ".efi").is_some() {
names.push(file_name);
}
}
let mut found = Vec::new();
for file_name in names {
let path = format!("{}\\{}", directory, file_name);
match read_image(&mut root, &path) {
Ok(image) => found.push(UkiFile {
file_name,
machine: image.machine,
// The sections are dropped here, so only the small entries are kept per image.
profiles: uki_profiles(&image.sections)
.into_iter()
.map(|profile| UkiProfileEntry {
index: profile.index,
has_osrel: profile.sections.contains_key(".osrel"),
entry: BlsEntry::from_uki_profile_with(&profile, &path, strict),
info: profile.info,
})
.collect(),
}),
Err(error) => warn!("unable to read unified kernel image {}: {:#}", path, error),
}
}
Ok(found)
}
/// Reads the sections of the PE image at `path`.
fn read_image(root: &mut uefi::proto::media::file::Directory, path: &str) -> Result<PeImage> {
let name = CString16::try_from(path).context("invalid path")?;
let handle = root
.open(&name, FileMode::Read, FileAttribute::empty())
.context("unable to open")?;
let Some(file) = handle.into_regular_file() else {
bail!("not a regular file");
};
read_pe(&mut FileReader(file), &UKI_SECTIONS)
}
+4 -7
View File
@@ -20,13 +20,10 @@ pub fn generate(
context.insert(combination);
let context = context.freeze();
// Stamp the entry title and actions from the template.
let mut entry = list.entry.clone();
entry.actions = entry
.actions
.into_iter()
.map(|action| context.stamp(action))
.collect();
// The actions are stamped with the final context of the entry when it is booted,
// so that variants and the values of the entry can override the values of the generator.
let entry = list.entry.clone();
// Push the entry into the list with the new context.
entries.push(BootableEntry::new(
index.to_string(),
+1 -35
View File
@@ -1,46 +1,12 @@
use crate::context::SproutContext;
use crate::entries::BootableEntry;
use crate::generators::list;
use alloc::collections::BTreeMap;
use alloc::rc::Rc;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use anyhow::Result;
use edera_sprout_config::generators::list::ListConfiguration;
use edera_sprout_config::generators::matrix::MatrixConfiguration;
/// Builds out multiple generations of `input` based on a matrix style.
/// For example, if input is: {"x": ["a", "b"], "y": ["c", "d"]}
/// It will produce:
/// x: a, y: c
/// x: a, y: d
/// x: b, y: c
/// x: b, y: d
fn build_matrix(input: &BTreeMap<String, Vec<String>>) -> Vec<BTreeMap<String, String>> {
// Convert the input into a vector of tuples.
let items: Vec<(String, Vec<String>)> = input.clone().into_iter().collect();
// The result is a vector of maps.
let mut result: Vec<BTreeMap<String, String>> = vec![BTreeMap::new()];
for (key, values) in items {
let mut new_result = Vec::new();
// Produce all the combinations of the input values.
for combination in &result {
for value in &values {
let mut new_combination = combination.clone();
new_combination.insert(key.clone(), value.clone());
new_result.push(new_combination);
}
}
result = new_result;
}
result.into_iter().filter(|item| !item.is_empty()).collect()
}
use edera_sprout_parsing::build_matrix;
/// Generates a set of entries using the specified `matrix` configuration in the `context`.
pub fn generate(
+571 -137
View File
@@ -3,28 +3,30 @@
#![no_main]
extern crate alloc;
use crate::context::{RootContext, SproutContext};
use crate::entries::BootableEntry;
use crate::options::SproutOptions;
use crate::options::parser::OptionsRepresentable;
use crate::phases::phase;
use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::ToString;
use alloc::vec::Vec;
use crate::{
context::{RootContext, SproutContext},
entries::BootableEntry,
options::SproutOptions,
phases::phase,
};
use alloc::{
collections::{BTreeMap, BTreeSet},
format,
string::{String, ToString},
vec::Vec,
};
use anyhow::{Context, Result, bail};
use core::ops::Deref;
use core::time::Duration;
use edera_sprout_config::RootConfiguration;
use eficore::bootloader_interface::{BootloaderInterface, BootloaderInterfaceTimeout};
use eficore::partition::PartitionGuidForm;
use eficore::platform::timer::PlatformTimer;
use eficore::platform::tpm::PlatformTpm;
use eficore::secure::SecureBoot;
use eficore::setup;
use core::{ops::Deref, time::Duration};
use edera_sprout_bls::{LoaderConf, LoaderTimeout, compare_versions};
use edera_sprout_config::{DEFAULT_MENU_TIMEOUT_SECONDS, RootConfiguration};
use eficore::{
bootloader_interface::{BootloaderInterface, BootloaderInterfaceTimeout},
partition::PartitionGuidForm,
platform::{timer::PlatformTimer, tpm::PlatformTpm},
setup,
};
use log::{error, info, warn};
use uefi::entry;
use uefi::proto::device_path::LoadedImageDevicePath;
use uefi::{entry, proto::device_path::LoadedImageDevicePath, runtime::ResetType};
use uefi_raw::Status;
/// actions: Code that can be configured and executed by Sprout.
@@ -33,6 +35,9 @@ pub mod actions;
/// autoconfigure: Autoconfigure Sprout based on the detected environment.
pub mod autoconfigure;
/// boot_counter: Consume the boot counter tries of BLS entries.
pub mod boot_counter;
/// config: Sprout configuration mechanism.
pub mod config;
@@ -63,40 +68,84 @@ pub mod phases;
/// sbat: Secure Boot Attestation section.
pub mod sbat;
/// utils: Utility functions that are used by other parts of Sprout.
pub mod utils;
/// The delay to wait for when an error occurs in Sprout.
const DELAY_ON_ERROR: Duration = Duration::from_secs(10);
/// 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.
if SecureBoot::enabled().context("unable to determine Secure Boot status")? {
warn!("Sprout Secure Boot is in beta. Some functionality may not work as expected.");
/// 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()
})
}
/// Loads the loader.conf of the partition Sprout was loaded from.
/// A missing loader.conf has no settings.
fn load_loader_conf(context: &SproutContext) -> Result<LoaderConf> {
let Ok(content) = eficore::path::read_file_contents(
Some(context.root().loaded_image_path()?),
"\\loader\\loader.conf",
) else {
return Ok(LoaderConf::default());
};
// Measure the loader.conf into the TPM, as it changes how Sprout boots.
// A failure to measure is only a warning, as it was not Sprout's own configuration.
if let Err(error) = PlatformTpm::log_event(
PlatformTpm::PCR_BOOT_LOADER_CONFIG,
&content,
"sprout: loader.conf",
) {
warn!(
"unable to measure the loader.conf file into the TPM: {:#}",
error
);
}
// Read what is valid, as a stray byte should not discard the whole file.
let content = String::from_utf8_lossy(&content);
Ok(LoaderConf::parse(&content))
}
/// Run Sprout, returning an error if one occurs.
fn run(reboot_on_error: &mut bool) -> Result<()> {
// Start the platform timer.
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")?;
@@ -123,20 +172,33 @@ 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"),
);
// Strict mode can be asked for on the command line or in the configuration.
let mut options = options;
options.bls_strict_mode |= config.options.bls_strict_mode;
if options.bls_strict_mode {
info!("BLS strict mode is enabled");
}
// Create the root context.
let mut root = RootContext::new(loaded_image_path, timer, options);
@@ -162,7 +224,12 @@ fn run() -> Result<()> {
// If --autoconfigure is specified or the loaded configuration has autoconfigure enabled,
// trigger the autoconfiguration mechanism.
if context.root().options().autoconfigure || config.options.autoconfigure {
autoconfigure::autoconfigure(&mut config).context("unable to autoconfigure")?;
autoconfigure::autoconfigure(
&mut config,
context.root().options().bls_strict_mode,
context.root().loaded_image_path()?,
)
.context("unable to autoconfigure")?;
}
// Unload the context so that it can be modified.
@@ -220,147 +287,501 @@ fn run() -> Result<()> {
for (name, generator) in config.generators {
let context = context.fork().freeze();
// We will prefix all entries with [name]-, provided the name is not pinned.
let prefix = format!("{}-", name);
// Add all the entries generated by the generator to the entry list.
// The generator specifies the context associated with the entry.
for mut entry in generators::generate(context.clone(), &generator)? {
// If the entry name is not pinned, prepend the name prefix.
if !entry.is_pin_name() {
entry.prepend_name_prefix(&prefix);
}
entries.extend(generators::generate(&name, context, &generator)?);
}
entries.push(entry);
// Entry names are used to select entries, so duplicates make the selection ambiguous.
// This can happen when two generators pin names, like two BLS generators reading the
// same BLS directory.
let mut seen_names = BTreeSet::new();
for entry in &entries {
if !seen_names.insert(entry.name()) {
warn!(
"more than one entry is named {}, selecting it by name is ambiguous",
entry.name()
);
}
}
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 {
// If the entry matches the default entry, mark it as the default entry.
if entry.is_match(default_entry) {
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.id()))
.context("unable to set entries in bootloader interface"),
);
// Execute the late phase.
phase(context.clone(), &config.phases.late).context("unable to execute late phase")?;
// Load the loader.conf of the partition Sprout was loaded from.
let loader_conf = load_loader_conf(&context).context("unable to load loader.conf")?;
for warning in &loader_conf.warnings {
warn!("{}", warning);
}
// Acquire the timeout setting from the bootloader interface.
let bootloader_interface_timeout =
BootloaderInterface::get_timeout().context("unable to get bootloader interface timeout")?;
// Acquire the timeouts from the bootloader interface.
let bootloader_interface_timeouts = advisory(
BootloaderInterface::get_timeouts().context("unable to get bootloader interface timeouts"),
);
// 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 preferred entry from the bootloader interface.
let bootloader_interface_preferred_entry = advisory(
BootloaderInterface::get_preferred_entry()
.context("unable to get bootloader interface preferred entry"),
);
// Acquire the entry that was saved by the previous boot.
let mut last_booted_entry = advisory(
BootloaderInterface::get_last_booted_entry()
.context("unable to get last booted entry from bootloader interface"),
);
// Whether to follow the specification and systemd-boot exactly.
let strict = context.root().options().bls_strict_mode;
// 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();
let force_boot_entry = context.root().options().boot.clone();
// If --force-menu is specified, show the boot menu regardless of the value of --boot.
let mut force_boot_menu = context.root().options().force_menu;
// Determine the menu timeout in seconds based on the options or configuration.
// We prefer the options over the configuration to allow for overriding.
let mut menu_timeout = context
.root()
.options()
.menu_timeout
.unwrap_or(config.options.menu_timeout);
// Pick the menu timeout from the first source that specifies one, in this order:
// the one-shot timeout, --menu-timeout, LoaderConfigTimeout, the configuration, loader.conf.
// The bootloader interface is what tools like bootctl set, so it outranks the configuration.
let seconds = |seconds: Option<u64>| {
seconds.map_or(
BootloaderInterfaceTimeout::Unspecified,
BootloaderInterfaceTimeout::Timeout,
)
};
let loader_conf_timeout = match loader_conf.timeout {
None => BootloaderInterfaceTimeout::Unspecified,
Some(LoaderTimeout::Seconds(0)) | Some(LoaderTimeout::Hidden) => {
BootloaderInterfaceTimeout::MenuHidden
}
Some(LoaderTimeout::Seconds(timeout)) => BootloaderInterfaceTimeout::Timeout(timeout),
Some(LoaderTimeout::Disabled) => BootloaderInterfaceTimeout::MenuDisabled,
Some(LoaderTimeout::Force) => BootloaderInterfaceTimeout::MenuForce,
};
let timeout = [
bootloader_interface_timeouts.oneshot,
seconds(context.root().options().menu_timeout),
bootloader_interface_timeouts.direct,
seconds(config.options.menu_timeout),
loader_conf_timeout,
]
.into_iter()
.find(|timeout| !matches!(timeout, BootloaderInterfaceTimeout::Unspecified))
.unwrap_or_default();
// Apply bootloader interface timeout settings.
match bootloader_interface_timeout {
// The menu timeout in seconds, if no source specified one. systemd-boot does not show the
// menu by default, while outside of strict mode it is shown for a while.
let mut menu_timeout = if strict {
0
} else {
DEFAULT_MENU_TIMEOUT_SECONDS
};
// Whether the boot menu should wait for the user, instead of counting down.
let mut wait_for_user = false;
// Whether the menu is disabled, so that a key press can't bring it up.
let mut menu_disabled = false;
// Apply the chosen timeout.
match 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::MenuHidden | BootloaderInterfaceTimeout::MenuDisabled => {
BootloaderInterfaceTimeout::MenuForceTimeout(timeout) => {
// Force the boot menu with the specified timeout.
force_boot_menu = true;
menu_timeout = timeout;
}
BootloaderInterfaceTimeout::MenuHidden => {
// Hide the boot menu by setting the timeout to zero.
menu_timeout = 0;
}
BootloaderInterfaceTimeout::MenuDisabled => {
// Hide the boot menu, and don't let a key press show it.
menu_timeout = 0;
menu_disabled = true;
}
BootloaderInterfaceTimeout::Timeout(timeout) => {
// Configure the timeout to the specified value.
menu_timeout = timeout;
}
BootloaderInterfaceTimeout::Unspecified => {
// Do nothing.
}
}
// 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 !strict {
info!(
"no menu timeout is set, so the menu is shown for {} seconds \
(systemd-boot and strict mode do not show it)",
DEFAULT_MENU_TIMEOUT_SECONDS
);
}
}
}
// Apply bootloader interface oneshot entry settings.
// If set, we will force booting the oneshot entry.
if let Some(ref bootloader_interface_oneshot_entry) = bootloader_interface_oneshot_entry {
force_boot_entry = Some(bootloader_interface_oneshot_entry.clone());
}
// Whether the default entry is saved on every boot, which is what `@saved` asks for.
// The saved entry is selected by `@saved` from the bootloader interface, or from loader.conf
// when the bootloader interface does not set an entry.
let is_saved = |value: &Option<String>| {
value
.as_deref()
.is_some_and(|v| v.eq_ignore_ascii_case("@saved"))
};
let use_saved_entry = is_saved(&bootloader_interface_default_entry)
|| (bootloader_interface_default_entry.is_none() && is_saved(&loader_conf.default));
// If no entries were the default, pick the first entry as the default entry.
if entries.iter().all(|entry| !entry.is_default())
&& let Some(entry) = entries.first_mut()
{
entry.mark_default();
}
// 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}"))?
} else {
// Delegate to the menu to select an entry to boot.
menu::select(&timer, menu_timeout, &entries)
.context("unable to select entry via boot menu")?
// Turns the value of an entry setting into a pattern. `@saved` is the entry that was saved
// by the previous boot, and any other value that starts with `@` is not supported.
let entry_pattern = |value: Option<String>| -> Option<String> {
let value = value?;
if value.eq_ignore_ascii_case("@saved") {
return last_booted_entry.clone();
}
if value.starts_with('@') {
warn!("ignoring unsupported entry setting '{}'", value);
return None;
}
Some(value)
};
// 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")?;
// Pick the default entry from the first source that matches an entry, where the bootloader
// interface, which is what tools like bootctl set, outranks the configuration. A source that matches
// no entry, such as when the entry was removed, is ignored so that the next source is used.
// The preferred entry sources never use an entry with no boot counter tries left.
// Each source has whether it matches by id, and whether it skips bad entries.
// In strict mode, the one-shot entry is the default entry for this boot, like in
// systemd-boot, and the menu or its timeout still decides what is booted. This also holds
// when the menu is forced, as the one-shot entry is then not booted at once.
let oneshot_source = if strict || force_boot_menu {
bootloader_interface_oneshot_entry.clone()
} else {
None
};
for (source, value, by_id, skip_bad) in [
("bootloader interface one-shot", oneshot_source, true, false),
(
"bootloader interface preferred",
bootloader_interface_preferred_entry.clone(),
true,
true,
),
(
"loader.conf preferred",
loader_conf.preferred.clone(),
true,
true,
),
(
"bootloader interface default",
bootloader_interface_default_entry.clone(),
true,
false,
),
(
"configuration",
config.options.default_entry.clone(),
false,
false,
),
(
"loader.conf default",
loader_conf.default.clone(),
true,
false,
),
] {
// The configuration is a pattern as it is. The other sources can be `@saved`.
let pattern = if by_id { entry_pattern(value) } else { value };
let Some(pattern) = pattern else {
continue;
};
let pattern_matches = |entry: &BootableEntry| {
if by_id {
entry.is_match_id(&pattern)
} else {
entry.is_match(&pattern)
}
};
let matches =
|entry: &BootableEntry| pattern_matches(entry) && !(skip_bad && entry.is_bad());
// A source that only matches entries with no boot counter tries left is skipped while
// another source could pick a usable entry, unless every entry is bad. systemd-boot
// does not look at the tries for the default entry, so strict mode does not either.
let any_usable = entries.iter().any(|entry| !entry.is_bad());
if entries
.iter()
.any(|entry| matches(entry) && (strict || !any_usable || !entry.is_bad()))
{
// Mark the matching entries as the default and unmark all the others.
for entry in &mut entries {
if matches(entry) {
entry.mark_default();
} else {
entry.unmark_default();
}
}
break;
}
if !skip_bad
&& entries
.iter()
.any(|entry| pattern_matches(entry) && entry.is_bad())
{
warn!(
"the {} entry '{}' has no boot counter tries left, so it is not the default \
(systemd-boot and strict mode use it)",
source, pattern
);
continue;
}
warn!("ignoring {} entry '{}': no matching entry", source, pattern);
}
// Execute all the actions for the selected entry.
for action in &entry.declaration().actions {
let action = entry.context().stamp(action);
actions::execute(entry.context().clone(), &action)
.context(format!("unable to execute action '{}'", action))?;
// Entries that have no boot counter tries left are never picked automatically.
// If no entries are the default, pick the first usable entry as the default entry.
let default_flags = edera_sprout_bls::resolve_default_flags(
&entries
.iter()
.map(|entry| {
// In strict mode, an entry that was asked for stays the default even without tries.
let bad = entry.is_bad() && !(strict && entry.is_default());
(entry.is_default(), bad, entry.is_extra_profile())
})
.collect::<Vec<_>>(),
);
for (entry, default) in entries.iter_mut().zip(default_flags) {
if default {
entry.mark_default();
} else {
entry.unmark_default();
}
}
loop {
// Convert the menu timeout to a duration.
// 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);
// The settings of the graphical menu, which only uses the mouse when it is enabled.
let graphical = &config.menu_styles.graphical;
// Find the forced boot entry, unless the boot menu is forced.
// The oneshot entry from the bootloader interface is forced by id, and it is used instead of
// --boot. If the forced boot entry can't be found, such as when it was removed,
// the boot menu is used instead.
let forced_entry = if force_boot_menu {
None
} else if !strict && let Some(ref oneshot) = bootloader_interface_oneshot_entry {
let entry = BootableEntry::find_id(oneshot, entries.iter());
match entry {
Some(entry) => info!(
"booting the one-shot entry {} without showing the menu \
(systemd-boot and strict mode only make it the default)",
entry.id()
),
None => warn!("unable to find entry '{}', using the boot menu", oneshot),
}
entry
} else {
force_boot_entry.as_ref().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.
None => menu::select(
&timer,
menu_timeout,
menu_disabled,
menu_style,
graphical,
&entries,
)
.context("unable to select entry via boot menu")?,
};
// Tell the bootloader interface what the selected entry is.
advisory(
BootloaderInterface::set_selected_entry(entry.id())
.context("unable to set selected entry in bootloader interface"),
);
// Save the selected entry for the next boot when `@saved` asks for it, and forget a saved
// entry that is no longer used. A forced boot, such as a one-shot entry, changes nothing,
// and neither does an unchanged value.
let oneshot_selected = strict
&& bootloader_interface_oneshot_entry
.as_deref()
.is_some_and(|oneshot| entry.is_match_id(oneshot));
if forced_entry.is_none() && !oneshot_selected {
let id = entry.id();
if use_saved_entry {
if !last_booted_entry
.as_deref()
.is_some_and(|saved| saved.eq_ignore_ascii_case(&id))
{
advisory(
BootloaderInterface::set_last_booted_entry(&id)
.context("unable to save last booted entry in bootloader interface"),
);
last_booted_entry = Some(id.clone());
}
} else if last_booted_entry.is_some() {
advisory(
BootloaderInterface::remove_last_booted_entry()
.context("unable to remove last booted entry in bootloader interface"),
);
last_booted_entry = None;
}
}
// 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")?;
// Consume a try from the boot counter of the selected entry.
// Failing to do so must not prevent the entry from booting.
// An entry with no tries left, which was picked by hand, is counted too, so that it can
// still be marked as good.
// The tries that were left before this boot used one up, if it did.
let mut consumed_tries_left = None;
let mut entry_context = entry.context();
if let Some(target) = entry.boot_counter()
&& target.counting
{
match target.consume() {
Ok(path) => {
info!("updated boot counter of entry {}: {}", entry.name(), path);
consumed_tries_left = Some(target.counter.tries_left);
// A unified kernel image is the entry file itself, so it has to be booted by
// its new name.
if let Some(chainload) = entry_context.get("chainload")
&& let Some(directory) = chainload.len().checked_sub(target.file_name.len())
&& chainload
.get(directory..)
.is_some_and(|name| name.eq_ignore_ascii_case(&target.file_name))
&& let Some(renamed) =
path.to_string().rsplit('\\').next().map(String::from)
{
let renamed = format!("{}{}", &chainload[..directory], renamed);
let mut context = entry_context.fork();
context.set("chainload", renamed);
entry_context = context.freeze();
}
// Tell the system where the counter is, so it can mark the boot as good.
let path = edera_sprout_bls::boot_path(&path.to_string());
advisory(
BootloaderInterface::set_boot_count_path(&path)
.context("unable to set boot count path in bootloader interface"),
);
}
Err(error) => warn!(
"unable to update boot counter of entry {}: {:#}",
entry.name(),
error
),
}
}
// Decide whether a failure to start the entry reboots the machine. This is only decided now,
// as an earlier failure did not use up a try, so a reboot would not make any progress.
*reboot_on_error = loader_conf
.reboot_on_error
.should_reboot(consumed_tries_left);
// Execute all the actions for the selected entry.
for action in &entry.declaration().actions {
let action = entry_context.stamp(action);
actions::execute(entry_context.clone(), &action)
.context(format!("unable to execute action '{}'", action))?;
}
// Once the image returns, which is not usual, systemd-boot shows the menu again and waits,
// while outside of strict mode Sprout goes on to exit to the firmware.
if strict {
info!("the boot entry returned, showing the menu again");
force_boot_menu = true;
wait_for_user = true;
menu_disabled = false;
continue;
}
info!(
"the boot entry returned, so Sprout exits to the firmware \
(systemd-boot and strict mode show the menu again)"
);
break;
}
Ok(())
@@ -382,15 +803,28 @@ fn efi_main() -> Status {
}
// Run Sprout, then handle the error.
let result = run();
let mut reboot_on_error = false;
let result = run(&mut reboot_on_error);
if let Err(ref error) = result {
// Print an error trace.
error!("sprout encountered an error: {}", error);
for (index, stack) in error.chain().enumerate() {
error!("[{}]: {}", index, stack);
}
// Sleep to allow the user to read the error.
// Sleep to allow the user to read the error. A reboot is announced first, so it is
// known why the machine is about to reset.
if reboot_on_error {
error!(
"rebooting in {} seconds after a failure to start the boot entry",
DELAY_ON_ERROR.as_secs()
);
}
uefi::boot::stall(DELAY_ON_ERROR);
// Reboot when asked to, so that the next boot can use the next try or entry.
if reboot_on_error {
uefi::runtime::reset(ResetType::COLD, Status::SUCCESS, None);
}
return Status::ABORTED;
}
+181 -138
View File
@@ -1,36 +1,59 @@
use crate::entries::BootableEntry;
use crate::menu::basic::BasicMenu;
use crate::menu::graphical::GraphicalMenu;
use crate::menu::simple::SimpleMenu;
use alloc::vec;
use anyhow::{Context, Result, bail};
use alloc::vec::Vec;
use anyhow::{Context, Result};
use core::time::Duration;
use edera_sprout_config::{GraphicalMenuConfiguration, MenuStyle};
use eficore::bootloader_interface::BootloaderInterface;
use eficore::platform::timer::PlatformTimer;
use log::{info, warn};
use log::warn;
use uefi::Event;
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,
/// font: The bitmap font that the graphical menu draws text with.
mod font;
/// graphical: A menu drawn on the graphics output that is used with the keyboard, or the mouse if enabled.
pub mod graphical;
/// logo: The Sprout logo that the graphical menu draws in a corner.
mod logo;
/// simple: A full-screen menu that selects entries with the arrow keys.
pub mod simple;
/// How long a hidden menu waits for a key press before the default entry is booted.
const HIDDEN_MENU_KEY_WAIT: Duration = Duration::from_millis(100);
/// 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,25 +67,38 @@ fn read(input: &mut Input, timeout: &Duration) -> Result<MenuOperation> {
.context("unable to create timer event")?
};
// The timeout is in increments of 100 nanoseconds.
let timeout_hundred_nanos = timeout.as_nanos() / 100;
let events = vec![timer_event, key_event];
// Check if the timeout is too large to fit into an u64.
if timeout_hundred_nanos > u64::MAX as u128 {
bail!("timeout duration overflow");
}
// Set a timer to trigger after the specified duration.
let trigger = TimerTrigger::Relative(timeout_hundred_nanos as u64);
uefi::boot::set_timer(&timer_event, trigger).context("unable to set timeout timer")?;
let mut 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(&mut 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.
@@ -71,129 +107,136 @@ 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?;
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.
let Some(key) = input.read_key().context("unable to read key")? else {
bail!("no key was pressed");
/// Wait until one of `events` is signaled, giving up once `timeout` passes.
/// If `timeout` is [None], this waits for an event indefinitely.
/// Returns the index of the event that was signaled, or [None] if the timeout passed.
pub fn wait_for_events(events: &[Event], timeout: Option<Duration>) -> Result<Option<usize>> {
// Timer event for timeout.
// SAFETY: The timer event creation allocated a timer pointer on the UEFI heap.
// This is validated safe as long as we are in boot services.
let timer_event = unsafe {
uefi::boot::create_event_ex(EventType::TIMER, Tpl::CALLBACK, None, None, None)
.context("unable to create timer event")?
};
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 timer is the last event so the indexes of the events that were passed are kept.
// The events are only borrowed, so the timer is the only one that is closed.
// SAFETY: The cloned event is only used to wait, and is not closed.
let mut all: Vec<Event> = events
.iter()
.map(|event| unsafe { event.unsafe_clone() })
.collect();
all.push(unsafe { timer_event.unsafe_clone() });
let result = (|| {
// 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(&timer_event, trigger).context("unable to set timeout timer")?;
}
// The escape key is used to exit the boot menu.
Key::Special(ScanCode::ESCAPE) => Ok(MenuOperation::Exit),
let index = uefi::boot::wait_for_event(&all)
.discard_errdata()
.context("unable to wait for event")?;
Ok((index != events.len()).then_some(index))
})();
// If the special key is unknown, do nothing.
Key::Special(_) => Ok(MenuOperation::Nop),
}
}
/// Selects an entry from the list of entries using the boot menu.
fn select_with_input<'a>(
input: &mut Input,
timeout: Duration,
entries: &'a [BootableEntry],
) -> Result<&'a BootableEntry> {
loop {
// If the timeout is not zero, let's display the boot menu.
if !timeout.is_zero() {
// Until a pretty menu is available, we just print all the entries.
info!("Boot Menu:");
for (index, entry) in entries.iter().enumerate() {
let title = entry.context().stamp(&entry.declaration().title);
info!(" [{}] {}", index, title);
}
}
// 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;
}
// Close the timer event that we acquired, without masking an error from waiting.
if let Err(error) = uefi::boot::close_event(timer_event) {
if result.is_err() {
warn!("unable to close timer event: {}", error);
} else {
return Err(error).context("unable to close timer event");
}
}
result
}
/// 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. A zero `timeout` hides the menu, but
/// the menu is still shown if a key is pressed right away, unless `menu_disabled` is set.
pub fn select<'live>(
timer: &'live PlatformTimer,
timeout: Duration,
timeout: Option<Duration>,
menu_disabled: bool,
style: MenuStyle,
graphical: &GraphicalMenuConfiguration,
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")?;
// A hidden menu gives the user a moment to ask for the menu with a key press.
let timeout = if timeout.is_some_and(|timeout| timeout.is_zero()) && !menu_disabled {
match uefi::system::with_stdin(|input| read_key(input, Some(HIDDEN_MENU_KEY_WAIT))) {
Ok(Some(_)) => None,
Ok(None) => timeout,
Err(error) => {
warn!("unable to check for a key press: {:#}", error);
timeout
}
}
} else {
timeout
};
// Acquire the standard input device and run the boot menu.
uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries))
// Notify the bootloader interface that we are about to display the menu.
// 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
);
}
// Pick the menu that implements the requested style.
match style {
MenuStyle::Basic => BasicMenu.select(timeout, entries),
// The graphical menu needs a graphics output, which not every machine has.
// If it fails, the simple menu is used instead, which falls back to the basic menu.
MenuStyle::Graphical => GraphicalMenu {
enable_mouse: graphical.enable_mouse,
enable_logo: graphical.enable_logo,
enable_animation: graphical.enable_animation,
}
.select(timeout, entries)
.or_else(|error| {
warn!(
"unable to show the graphical boot menu, using the simple menu: {:#}",
error
);
select_simple(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 => select_simple(timeout, entries),
}
}
/// Shows the simple boot menu, or the basic boot menu if the console does not support it.
/// See [BootMenu::select] for how `timeout` is handled.
fn select_simple(timeout: Option<Duration>, entries: &[BootableEntry]) -> Result<&BootableEntry> {
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))
}
}
+118
View File
@@ -0,0 +1,118 @@
/// The width and height of a glyph in pixels.
pub const GLYPH_SIZE: usize = 8;
/// A glyph as eight rows from top to bottom, where the lowest bit of a row is its leftmost pixel.
pub type Glyph = [u8; GLYPH_SIZE];
/// Three dots, which are drawn where a title is cut off.
pub const ELLIPSIS: Glyph = [0x00, 0x00, 0x00, 0x00, 0x00, 0xDB, 0xDB, 0x00];
/// The glyphs for the printable ASCII characters from space to tilde.
/// This is the public domain 8x8 font that is commonly used by hobby operating systems.
const GLYPHS: [Glyph; 95] = [
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
[0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00],
[0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
[0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00],
[0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00],
[0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00],
[0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00],
[0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00],
[0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00],
[0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00],
[0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00],
[0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00],
[0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06],
[0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00],
[0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00],
[0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00],
[0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00],
[0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00],
[0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00],
[0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00],
[0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00],
[0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00],
[0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00],
[0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00],
[0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00],
[0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00],
[0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00],
[0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06],
[0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00],
[0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00],
[0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00],
[0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00],
[0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00],
[0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00],
[0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00],
[0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00],
[0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00],
[0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00],
[0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00],
[0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00],
[0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00],
[0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
[0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00],
[0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00],
[0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00],
[0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00],
[0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00],
[0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00],
[0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00],
[0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00],
[0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00],
[0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00],
[0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
[0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00],
[0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00],
[0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00],
[0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00],
[0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00],
[0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00],
[0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00],
[0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00],
[0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00],
[0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00],
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF],
[0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00],
[0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00],
[0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00],
[0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00],
[0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00],
[0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00],
[0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00],
[0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F],
[0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00],
[0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
[0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E],
[0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00],
[0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
[0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00],
[0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00],
[0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00],
[0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F],
[0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78],
[0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00],
[0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00],
[0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00],
[0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00],
[0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00],
[0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00],
[0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00],
[0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F],
[0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00],
[0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00],
[0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00],
[0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00],
[0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
];
/// Get the glyph for `c`, or a question mark for anything that isn't printable ASCII.
pub fn glyph(c: char) -> &'static Glyph {
let index = if c.is_ascii() && (' '..='~').contains(&c) {
c as usize - ' ' as usize
} else {
'?' as usize - ' ' as usize
};
&GLYPHS[index]
}
+957
View File
@@ -0,0 +1,957 @@
use crate::entries::BootableEntry;
use crate::menu::font::{self, ELLIPSIS, GLYPH_SIZE, Glyph};
use crate::menu::logo::{LOGO, LOGO_HEIGHT, LOGO_WIDTH};
use crate::menu::{BootMenu, wait_for_events};
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use anyhow::{Context, Result, bail};
use core::time::Duration;
use eficore::framebuffer::Framebuffer;
use uefi::boot::{OpenProtocolParams, ScopedProtocol};
use uefi::proto::ProtocolPointer;
use uefi::proto::console::gop::{BltPixel, GraphicsOutput};
use uefi::proto::console::pointer::{AbsolutePointer, Pointer};
use uefi::proto::console::text::{Input, Key, ScanCode};
use uefi::proto::device_path::DevicePath;
use uefi::{Event, Handle};
/// How often the countdown and the logo are updated.
const TICK: Duration = Duration::from_millis(125);
/// The number of ticks in one bounce of the logo.
const BOUNCE_TICKS: usize = 16;
/// How many pixels the cursor moves for each millimeter that a relative pointer reports.
const PIXELS_PER_MILLIMETER: i64 = 8;
/// The title shown above the entries.
const TITLE: &str = "sprout";
/// The hint shown once the countdown has been stopped.
const HINT: &str = "Pick an entry with the arrow keys.";
/// The hint shown instead of [HINT] when the mouse is in use.
const HINT_MOUSE: &str = "Pick an entry with the arrow keys or the mouse.";
/// The color of the top of the background, which fades into [BACKGROUND_BOTTOM].
const BACKGROUND_TOP: (u8, u8, u8) = (0x3a, 0x1c, 0x4e);
/// The color of the bottom of the background.
const BACKGROUND_BOTTOM: (u8, u8, u8) = (0x16, 0x0c, 0x24);
/// The widest that the status line gets, which the panel has room for.
const STATUS_WIDEST: &str = "Boot in 999 s.";
/// The color of the panel behind the entries.
const PANEL: BltPixel = BltPixel::new(0x22, 0x12, 0x34);
/// The color of the edge of the panel, the line below the entries and the scrollbar track.
const PANEL_EDGE: BltPixel = BltPixel::new(0x4a, 0x2e, 0x66);
/// The color of the pill behind the selected entry, taken from the Sprout logo.
const PILL_SELECTED: BltPixel = BltPixel::new(0xff, 0x7a, 0xb8);
/// The color of the title.
const ACCENT: BltPixel = BltPixel::new(0xff, 0xa6, 0xd0);
/// The color of entries that aren't selected.
const TEXT: BltPixel = BltPixel::new(0xf3, 0xd9, 0xea);
/// The color of the selected entry.
const TEXT_SELECTED: BltPixel = BltPixel::new(0x3a, 0x10, 0x2c);
/// The color of the status line.
const TEXT_MUTED: BltPixel = BltPixel::new(0xb9, 0x9a, 0xc9);
/// The color that the cursor is filled with.
const CURSOR_FILL: BltPixel = BltPixel::new(0xff, 0xff, 0xff);
/// The color of the outline of the cursor.
const CURSOR_OUTLINE: BltPixel = BltPixel::new(0x3a, 0x10, 0x2c);
/// The shape of the cursor, where `X` is the outline and `.` is the fill.
const CURSOR: [&str; 16] = [
"X ",
"XX ",
"X.X ",
"X..X ",
"X...X ",
"X....X ",
"X.....X ",
"X......X ",
"X.......X ",
"X........X ",
"X.....XXXXX",
"X..X..X ",
"X.X X..X ",
"XX X..X ",
"X X..X ",
" XXXX ",
];
/// A graphical boot menu that selects entries with the keyboard, or the mouse if enabled.
pub struct GraphicalMenu {
/// Whether the mouse is used when the firmware has a pointing device.
pub enable_mouse: bool,
/// Whether the logo is drawn next to the name.
pub enable_logo: bool,
/// Whether the logo bounces.
pub enable_animation: bool,
}
/// The pointing devices of the firmware.
/// Firmware commonly has both kinds of protocol, even if one has no device behind it.
struct Mouse {
/// A device that reports how far it has moved, like a mouse.
relative: Option<ScopedProtocol<Pointer>>,
/// A device that reports where it is, like a touch screen or the tablet of a virtual machine.
absolute: Option<ScopedProtocol<AbsolutePointer>>,
}
/// Open the pointer protocol `P`, preferring the one on the console input handle.
/// That one belongs to the console splitter, which combines every device and is never removed.
/// Otherwise, the first device that has the protocol is used.
fn open_pointer<P: ProtocolPointer + ?Sized>() -> Option<ScopedProtocol<P>> {
// SAFETY: The handle in the system table is either null or a valid handle.
let console = uefi::table::system_table_raw()
.and_then(|table| unsafe { Handle::from_ptr(table.as_ref().stdin_handle) });
console
.and_then(|handle| eficore::handle::open_shared::<P>(handle).ok())
.or_else(|| {
uefi::boot::get_handle_for_protocol::<P>()
.and_then(eficore::handle::open_shared::<P>)
.ok()
})
}
impl Mouse {
/// Open the pointing devices, if the firmware has any.
fn open() -> Option<Self> {
let relative = open_pointer::<Pointer>();
let absolute = open_pointer::<AbsolutePointer>();
(relative.is_some() || absolute.is_some()).then_some(Self { relative, absolute })
}
/// The events that are signaled when a device has input.
fn events(&self) -> Result<Vec<Event>> {
let mut events = Vec::new();
if let Some(pointer) = &self.relative {
events.push(pointer.wait_for_input_event());
}
if let Some(pointer) = &self.absolute {
events.push(pointer.wait_for_input_event());
}
events
.into_iter()
.collect::<uefi::Result<_>>()
.context("unable to acquire pointer event")
}
/// Move `cursor` within `bounds` to where the devices say it is.
/// Returns whether the button is held, or [None] if the devices have no new input.
fn poll(
&mut self,
cursor: &mut (usize, usize),
bounds: (usize, usize),
) -> Result<Option<bool>> {
let mut button = None;
let max = (
bounds.0.saturating_sub(1) as i64,
bounds.1.saturating_sub(1) as i64,
);
if let Some(pointer) = &mut self.relative {
// The movement is in counts, and the device reports how many are in a millimeter.
let mode = *pointer.mode();
if let Some(state) = pointer.read_state().context("unable to read pointer")? {
let step = |movement: i32, resolution: u64| {
movement as i64 * PIXELS_PER_MILLIMETER / resolution.max(1) as i64
};
let x = cursor.0 as i64 + step(state.relative_movement_x, mode.resolution_x);
let y = cursor.1 as i64 + step(state.relative_movement_y, mode.resolution_y);
*cursor = (x.clamp(0, max.0) as usize, y.clamp(0, max.1) as usize);
button = Some(state.left_button.into());
}
}
if let Some(pointer) = &mut self.absolute {
// The position is somewhere between the minimum and maximum of the device.
let mode = *pointer.mode();
if let Some(state) = pointer.read_state().context("unable to read pointer")? {
let scale = |value: u64, min: u64, device_max: u64, bound: i64| {
let span = device_max.saturating_sub(min).max(1) as i128;
(value.saturating_sub(min) as i128 * bound as i128 / span).min(bound as i128)
as usize
};
*cursor = (
scale(
state.current_x,
mode.absolute_min_x,
mode.absolute_max_x,
max.0,
),
scale(
state.current_y,
mode.absolute_min_y,
mode.absolute_max_y,
max.1,
),
);
button = Some(state.active_buttons & 1 != 0);
}
}
Ok(button)
}
}
/// The position and size of everything on the framebuffer.
/// The logo and the name are in a header, with a panel below it that holds the entries.
struct Layout {
/// How many pixels each pixel of the font is drawn as.
scale: usize,
/// The size of a character in pixels.
cell: usize,
/// The column that entries start at.
x: usize,
/// The row that the first entry starts at.
y: usize,
/// The width of each entry.
width: usize,
/// The height of each entry.
height: usize,
/// The space between entries.
gap: usize,
/// The number of entries that fit on the framebuffer.
visible: usize,
/// The column, row, width and height of the panel behind the entries.
panel: (usize, usize, usize, usize),
/// How round the corners of the panel are.
radius: usize,
/// The column that the text of an entry starts at.
text_x: usize,
/// How many characters of a title fit in an entry.
columns: usize,
/// The row of the line between the entries and the status line.
footer: usize,
/// The column, row, width and height of the logo when it is on the ground, if it is shown.
logo: Option<(usize, usize, usize, usize)>,
/// How many pixels the logo rises while it bounces.
bounce: usize,
/// The column, row and scale of the name, if there is room for it.
title: Option<(usize, usize, usize)>,
/// Whether there are more entries than fit, which adds a scrollbar.
scrolls: bool,
}
impl Layout {
/// Calculate the layout of `entries` on a framebuffer of `screen` width and height.
fn new(screen: (usize, usize), entries: &[BootableEntry], logo: bool, animate: bool) -> Self {
let (width, height) = screen;
// Large screens get larger text so that it stays readable, but not so large that the
// titles take up the whole screen.
let scale = (height / 540).min(width / 640).clamp(1, 3);
let cell = GLYPH_SIZE * scale;
let margin = (cell * 2).max(height / 20).min(width / 8);
let padding = cell * 3 / 2;
let (row, gap) = (cell * 2, scale * 2);
let header_gap = cell * 2;
// The line between the entries and the status, and the status below it.
let footer_height = cell + scale + cell / 2 + cell;
// The panel is the width of the longest title, but never most of the screen.
let longest = entries
.iter()
.map(|entry| entry.title().chars().count())
.max()
.unwrap_or(0);
let status = HINT.len().max(HINT_MOUSE.len()).max(STATUS_WIDEST.len());
let panel_width = ((longest + 3) * cell + padding * 2)
.max(40 * cell)
.max((status + 8) * cell)
.min(width.saturating_sub(margin * 2).min(width * 4 / 5));
// The header is as tall as the logo, and the logo needs room to bounce.
let fit = |logo: usize| {
let header = logo + logo / 16;
let space = height
.saturating_sub(margin * 2 + header + header_gap + padding * 2 + footer_height);
let visible = ((space + gap) / (row + gap)).clamp(1, entries.len().max(1));
(header, visible)
};
let mut logo_height = (LOGO_HEIGHT * 2).min(height / 7).max(32);
let (mut header, mut visible) = fit(logo_height);
// A short screen gets a smaller logo before it loses entries.
if visible < entries.len().min(3) {
logo_height = (logo_height / 2).max(32);
(header, visible) = fit(logo_height);
}
let logo_width = logo_height * LOGO_WIDTH / LOGO_HEIGHT;
let bounce = if animate { logo_height / 16 } else { 0 };
// The name is next to the logo, if the two fit together.
let title_scale = (logo_height / 32).clamp(scale, scale * 3);
let title_width = TITLE.len() * GLYPH_SIZE * title_scale;
// Without the logo, the name is alone and takes the place of the group.
let shown = if logo { logo_width + cell } else { 0 };
let fits = shown + title_width <= width.saturating_sub(margin * 2);
let group = match (logo, fits) {
(true, true) => shown + title_width,
(true, false) => logo_width,
(false, _) => title_width,
};
let group_x = width.saturating_sub(group) / 2;
let list = visible * (row + gap) - gap;
let panel_height = padding * 2 + list + footer_height;
let top = (height.saturating_sub(header + header_gap + panel_height) / 2).max(margin);
let panel_x = width.saturating_sub(panel_width) / 2;
let panel_y = top + header + header_gap;
// The scrollbar is in a column next to the entries.
let scrolls = visible < entries.len();
let gutter = if scrolls { cell } else { 0 };
let list_width = panel_width.saturating_sub(padding * 2 + gutter);
let list_y = panel_y + padding;
Self {
scale,
cell,
x: panel_x + padding,
y: list_y,
width: list_width,
height: row,
gap,
visible,
panel: (panel_x, panel_y, panel_width, panel_height),
radius: cell,
text_x: panel_x + padding + cell,
columns: list_width.saturating_sub(cell * 2) / cell,
footer: list_y + list + cell,
logo: logo.then_some((group_x, top + bounce, logo_width, logo_height)),
bounce,
title: fits.then_some((
group_x + shown,
top + bounce + logo_height.saturating_sub(GLYPH_SIZE * title_scale) / 2,
title_scale,
)),
scrolls,
}
}
/// The row that the entry at `slot` on the screen starts at.
fn row(&self, slot: usize) -> usize {
self.y + slot * (self.height + self.gap)
}
/// The slot of the entry that is at `position`, if there is one.
fn slot_at(&self, position: (usize, usize)) -> Option<usize> {
let (x, y) = (
position.0.checked_sub(self.x)?,
position.1.checked_sub(self.y)?,
);
let slot = y / (self.height + self.gap);
(x < self.width && y % (self.height + self.gap) < self.height && slot < self.visible)
.then_some(slot)
}
}
/// A display that the menu is drawn to.
struct Screen {
/// The graphics output of the display.
gop: ScopedProtocol<GraphicsOutput>,
/// What is drawn before it is blitted to the display.
fb: Framebuffer,
/// What was last blitted to the display, which only has to be updated where it differs.
shown: Framebuffer,
/// The parts of the menu that never change, which each frame starts from.
base: Framebuffer,
/// Whether the display has had a frame blitted to it.
drawn: bool,
/// Where the entries are on this display.
layout: Layout,
/// The index of the first entry that is on this display.
offset: usize,
}
impl Screen {
/// Open every display that the firmware has, or fail if it has none.
/// The console splitter has a graphics output that draws to every display. It has no device
/// path, so it is only used if there are no outputs for the displays themselves.
fn open_all(
entries: &[BootableEntry],
enable_logo: bool,
enable_animation: bool,
) -> Result<Vec<Self>> {
let handles = uefi::boot::find_handles::<GraphicsOutput>()
.context("unable to find a graphics output")?;
let has_device_path = |handle: &Handle| {
uefi::boot::test_protocol::<DevicePath>(OpenProtocolParams {
handle: *handle,
agent: uefi::boot::image_handle(),
controller: None,
})
.unwrap_or(false)
};
let displays = handles
.iter()
.copied()
.filter(has_device_path)
.collect::<Vec<_>>();
let handles = if displays.is_empty() {
handles
} else {
displays
};
let mut screens = Vec::new();
for handle in handles {
// Opening it exclusively would disconnect the firmware's text console from the
// display, and anything printed after the menu may not show up.
let Ok(gop) = eficore::handle::open_shared::<GraphicsOutput>(handle) else {
continue;
};
let (width, height) = gop.current_mode_info().resolution();
let (Ok(fb), Ok(shown), Ok(mut base)) = (
Framebuffer::new(width, height),
Framebuffer::new(width, height),
Framebuffer::new(width, height),
) else {
continue;
};
let layout = Layout::new((width, height), entries, enable_logo, enable_animation);
render_base(&mut base, &layout);
screens.push(Self {
gop,
fb,
shown,
base,
drawn: false,
layout,
offset: 0,
});
}
if screens.is_empty() {
bail!("unable to open a graphics output");
}
Ok(screens)
}
/// The size of the display in pixels.
fn size(&self) -> (usize, usize) {
(self.fb.width(), self.fb.height())
}
/// Where `position` on a display of size `from` is on this display, keeping its place
/// relative to the edges.
fn locate(&self, position: (usize, usize), from: (usize, usize)) -> (usize, usize) {
let scale = |value: usize, to: usize, from: usize| {
(value * to / from.max(1)).min(to.saturating_sub(1))
};
(
scale(position.0, self.fb.width(), from.0),
scale(position.1, self.fb.height(), from.1),
)
}
/// Scroll so that the `selected` entry is visible.
fn scroll_to(&mut self, selected: usize) {
if selected < self.offset {
self.offset = selected;
} else if selected >= self.offset + self.layout.visible {
self.offset = selected + 1 - self.layout.visible;
}
}
}
/// What is shown on the framebuffer.
struct Scene<'a> {
/// The entries that can be chosen.
entries: &'a [BootableEntry],
/// The index of the selected entry.
selected: usize,
/// The status line at the bottom of the screen.
status: String,
/// The number of ticks since the menu was shown, which moves the logo.
ticks: usize,
}
/// Fill `rows` with a gradient from `top` to `bottom`.
fn draw_background(fb: &mut Framebuffer, top: (u8, u8, u8), bottom: (u8, u8, u8)) {
let (width, height) = (fb.width(), fb.height());
let mix = |from: u8, to: u8, row: usize| {
(from as i64 + (to as i64 - from as i64) * row as i64 / height.max(1) as i64) as u8
};
for row in 0..height {
let color = BltPixel::new(
mix(top.0, bottom.0, row),
mix(top.1, bottom.1, row),
mix(top.2, bottom.2, row),
);
fb.fill_rect(0, row, width, 1, color);
}
}
/// Fill a rectangle that has rounded corners of `radius`.
fn draw_rounded(
fb: &mut Framebuffer,
x: usize,
y: usize,
width: usize,
height: usize,
radius: usize,
color: BltPixel,
) {
if width == 0 || height == 0 {
return;
}
let radius = radius.min(height / 2).min(width / 2);
for row in 0..height {
// How far the row is from the edge, where the corners curve in.
let edge = row.min(height - 1 - row);
let inset = if edge >= radius {
0
} else {
let rise = radius - edge;
(0..radius)
.find(|inset| (radius - inset).pow(2) + rise.pow(2) <= radius.pow(2))
.unwrap_or(radius)
};
fb.fill_rect(
x + inset,
y + row,
width.saturating_sub(inset * 2),
1,
color,
);
}
}
/// Fill a rectangle with the shape of a pill, which has fully rounded ends.
fn draw_pill(
fb: &mut Framebuffer,
x: usize,
y: usize,
width: usize,
height: usize,
color: BltPixel,
) {
draw_rounded(fb, x, y, width, height, height / 2, color);
}
/// Draw `glyph` at `x` and `y`, with each pixel of it drawn as `scale` by `scale` pixels.
fn draw_glyph(
fb: &mut Framebuffer,
x: usize,
y: usize,
scale: usize,
glyph: &Glyph,
color: BltPixel,
) {
for (row, bits) in glyph.iter().enumerate() {
for column in (0..GLYPH_SIZE).filter(|column| bits >> column & 1 != 0) {
fb.fill_rect(x + column * scale, y + row * scale, scale, scale, color);
}
}
}
/// Draw `text` starting at `x` and `y`, cut off with an ellipsis if it is over `columns` characters.
fn draw_text(
fb: &mut Framebuffer,
x: usize,
y: usize,
scale: usize,
columns: usize,
text: &str,
color: BltPixel,
) {
let cut = text.chars().count() > columns;
let shown = if cut {
columns.saturating_sub(1)
} else {
columns
};
for (index, c) in text.chars().take(shown).enumerate() {
draw_glyph(
fb,
x + index * GLYPH_SIZE * scale,
y,
scale,
font::glyph(c),
color,
);
}
if cut && columns > 0 {
draw_glyph(
fb,
x + shown * GLYPH_SIZE * scale,
y,
scale,
&ELLIPSIS,
color,
);
}
}
/// Draw `text` in the middle of `x` and `width` at `y`.
fn draw_centered(
fb: &mut Framebuffer,
x: usize,
width: usize,
y: usize,
scale: usize,
text: &str,
color: BltPixel,
) {
let columns = width / (GLYPH_SIZE * scale);
let used = text.chars().count().min(columns) * GLYPH_SIZE * scale;
draw_text(fb, x + (width - used) / 2, y, scale, columns, text, color);
}
/// Draw the logo above the entries, bouncing a little with each of `ticks`.
fn draw_logo(fb: &mut Framebuffer, layout: &Layout, ticks: usize) {
let Some((x, floor, width, height)) = layout.logo else {
return;
};
// The logo rises and falls by up to `bounce` pixels.
let phase = ticks % BOUNCE_TICKS;
let rise = phase.min(BOUNCE_TICKS - phase) * layout.bounce / (BOUNCE_TICKS / 2);
let y = floor - rise;
for row in 0..height {
let source_row = row * LOGO_HEIGHT / height * LOGO_WIDTH;
for column in 0..width {
let source = (source_row + column * LOGO_WIDTH / width) * 4;
let [red, green, blue, alpha] = LOGO[source..source + 4] else {
continue;
};
if alpha == 0 {
continue;
}
// The channels are premultiplied, so the color is the logo plus what shows through.
if let Some(pixel) = fb.pixel(x + column, y + row) {
let through = 255 - alpha as u32;
let blend =
|logo: u8, behind: u8| (logo as u32 + behind as u32 * through / 255) as u8;
*pixel = BltPixel::new(
blend(red, pixel.red),
blend(green, pixel.green),
blend(blue, pixel.blue),
);
}
}
}
}
/// Draw the track and the thumb of the scrollbar, which shows where `offset` is in `total`.
fn draw_scrollbar(fb: &mut Framebuffer, layout: &Layout, offset: usize, total: usize) {
let height = layout.visible * (layout.height + layout.gap) - layout.gap;
let x = layout.x + layout.width + layout.cell / 2 - layout.scale;
let width = layout.scale * 2;
fb.fill_rect(x, layout.y, width, height, PANEL_EDGE);
let thumb = (height * layout.visible / total)
.max(layout.cell)
.min(height);
let travel = total.saturating_sub(layout.visible).max(1);
let y = layout.y + (height - thumb) * offset.min(travel) / travel;
draw_rounded(fb, x, y, width, thumb, width / 2, ACCENT);
}
/// Draw the cursor with its tip at `position`.
fn draw_cursor(fb: &mut Framebuffer, scale: usize, position: (usize, usize)) {
for (row, line) in CURSOR.iter().enumerate() {
for (column, shape) in line.bytes().enumerate() {
let color = match shape {
b'X' => CURSOR_OUTLINE,
b'.' => CURSOR_FILL,
_ => continue,
};
fb.fill_rect(
position.0 + column * scale,
position.1 + row * scale,
scale,
scale,
color,
);
}
}
}
/// Draw the parts of the menu that never change to the framebuffer.
fn render_base(fb: &mut Framebuffer, layout: &Layout) {
draw_background(fb, BACKGROUND_TOP, BACKGROUND_BOTTOM);
if let Some((x, y, scale)) = layout.title {
draw_text(fb, x, y, scale, TITLE.len(), TITLE, ACCENT);
}
let (panel_x, panel_y, panel_width, panel_height) = layout.panel;
let edge = layout.scale;
draw_rounded(
fb,
panel_x,
panel_y,
panel_width,
panel_height,
layout.radius,
PANEL_EDGE,
);
draw_rounded(
fb,
panel_x + edge,
panel_y + edge,
panel_width.saturating_sub(edge * 2),
panel_height.saturating_sub(edge * 2),
layout.radius.saturating_sub(edge),
PANEL,
);
// The line between the entries and the status line.
let inner = panel_width.saturating_sub(layout.cell * 3);
let inner_x = panel_x + layout.cell * 3 / 2;
fb.fill_rect(inner_x, layout.footer, inner, layout.scale, PANEL_EDGE);
}
/// Draw the whole menu to the framebuffer, which already has the [render_base] drawn.
fn render(
fb: &mut Framebuffer,
layout: &Layout,
scene: &Scene,
offset: usize,
cursor: Option<(usize, usize)>,
) {
draw_logo(fb, layout, scene.ticks);
let (panel_x, _, panel_width, _) = layout.panel;
for (slot, (index, entry)) in scene
.entries
.iter()
.enumerate()
.skip(offset)
.take(layout.visible)
.enumerate()
{
let row = layout.row(slot);
let text_row = row + (layout.height - layout.cell) / 2;
// Only the selected entry has a pill, which keeps the list calm.
let text = if index == scene.selected {
draw_pill(
fb,
layout.x,
row,
layout.width,
layout.height,
PILL_SELECTED,
);
TEXT_SELECTED
} else {
TEXT
};
draw_text(
fb,
layout.text_x,
text_row,
layout.scale,
layout.columns,
entry.title(),
text,
);
}
if layout.scrolls {
draw_scrollbar(fb, layout, offset, scene.entries.len());
}
// The status line is below the line across the panel, with the position when it scrolls.
let inner = panel_width.saturating_sub(layout.cell * 3);
let inner_x = panel_x + layout.cell * 3 / 2;
let status_row = layout.footer + layout.scale + layout.cell / 2;
// The position is at the right edge, so the status keeps clear of it on both sides.
let keep_clear = if layout.scrolls { layout.cell * 8 } else { 0 };
draw_centered(
fb,
inner_x + keep_clear,
inner.saturating_sub(keep_clear * 2),
status_row,
layout.scale,
&scene.status,
TEXT_MUTED,
);
if layout.scrolls {
let position = format!("{}/{}", scene.selected + 1, scene.entries.len());
let width = position.len() * layout.cell;
draw_text(
fb,
(inner_x + inner).saturating_sub(width),
status_row,
layout.scale,
position.len(),
&position,
TEXT_MUTED,
);
}
if let Some(position) = cursor {
draw_cursor(fb, layout.scale, position);
}
}
/// Run the menu until an entry is chosen, returning the index of that entry.
/// The `selected` entry is booted if there is no input before `timeout` passes.
/// Without a timeout, the menu waits for the user.
fn run(
input: &mut Input,
mut mouse: Option<Mouse>,
screens: &mut [Screen],
timeout: Option<Duration>,
entries: &[BootableEntry],
selected: usize,
) -> Result<usize> {
// The cursor moves within the first screen, and is shown at the same place on the others.
let bounds = screens[0].size();
let page = screens[0].layout.visible;
let last = entries.len() - 1;
// The events that wake the menu up, which are keys and pointer input.
let mut events = Vec::from([input
.wait_for_key_event()
.context("unable to acquire key event")?]);
if let Some(mouse) = &mouse {
events.extend(mouse.events()?);
}
let mut scene = Scene {
entries,
selected,
status: String::new(),
ticks: 0,
};
let mut cursor = mouse.as_ref().map(|_| (bounds.0 / 2, bounds.1 / 2));
// The time left before booting, or None once input stops the countdown.
let mut remaining = timeout;
let mut button_down = false;
loop {
scene.status = match remaining {
Some(remaining) => format!("Boot in {} s.", remaining.as_millis().div_ceil(1000)),
None if mouse.is_some() => String::from(HINT_MOUSE),
None => String::from(HINT),
};
for screen in screens.iter_mut() {
screen.scroll_to(scene.selected);
let cursor = cursor.map(|position| screen.locate(position, bounds));
screen.fb.copy_from(&screen.base);
render(
&mut screen.fb,
&screen.layout,
&scene,
screen.offset,
cursor,
);
// Only the first frame has to fill the display, the rest only change parts of it.
if screen.drawn {
screen.fb.blit_changes(&screen.shown, &mut screen.gop)?;
} else {
screen.fb.blit(&mut screen.gop)?;
screen.drawn = true;
}
core::mem::swap(&mut screen.fb, &mut screen.shown);
}
// Wake up every tick to move the logo and update the countdown.
if wait_for_events(&events, Some(TICK))?.is_none() {
scene.ticks += 1;
if let Some(left) = remaining {
let left = left.saturating_sub(TICK);
if left.is_zero() {
return Ok(scene.selected);
}
remaining = Some(left);
}
continue;
}
// Some firmware signals an event without any input being available.
if let Some(key) = input.read_key().context("unable to read key")? {
remaining = None;
match key {
Key::Special(ScanCode::UP) => scene.selected = scene.selected.saturating_sub(1),
Key::Special(ScanCode::DOWN) => scene.selected = (scene.selected + 1).min(last),
Key::Special(ScanCode::HOME) => scene.selected = 0,
Key::Special(ScanCode::END) => scene.selected = last,
Key::Special(ScanCode::PAGE_UP) => {
scene.selected = scene.selected.saturating_sub(page)
}
Key::Special(ScanCode::PAGE_DOWN) => {
scene.selected = (scene.selected + page).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(scene.selected);
}
_ => {}
}
}
if let (Some(mouse), Some(cursor)) = (mouse.as_mut(), cursor.as_mut())
&& let Some(down) = mouse.poll(cursor, bounds)?
{
remaining = None;
// Hovering over an entry selects it, and pressing the button boots it.
let hovered = screens.iter().find_map(|screen| {
let position = screen.locate(*cursor, bounds);
screen
.layout
.slot_at(position)
.map(|slot| screen.offset + slot)
});
if let Some(entry) = hovered {
scene.selected = entry;
if down && !button_down {
return Ok(scene.selected);
}
}
button_down = down;
}
}
}
impl BootMenu for GraphicalMenu {
/// Selects an entry using the graphical menu.
/// The menu is drawn to every display, and fails if the firmware has none.
fn select<'a>(
&self,
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 mut screens = Screen::open_all(entries, self.enable_logo, self.enable_animation)?;
let mut mouse = self.enable_mouse.then(Mouse::open).flatten();
let result = uefi::system::with_stdin(|input| {
run(input, mouse.take(), &mut screens, timeout, entries, default)
});
// Clear the console so that anything printed after the menu is readable.
let _ = uefi::system::with_stdout(|output| output.clear());
Ok(&entries[result?])
}
}
+2
View File
@@ -0,0 +1,2 @@
// Include the generated logo in this file.
include!(concat!(env!("OUT_DIR"), "/logo.generated.rs"));
+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))
})
}
}
+154 -92
View File
@@ -1,10 +1,13 @@
use crate::options::parser::{OptionDescription, OptionForm, OptionsRepresentable};
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use anyhow::{Context, Result, bail};
/// The Sprout options parser.
pub mod parser;
use anyhow::{Context, Result, anyhow, bail};
use core::cell::Cell;
use core::ptr::null_mut;
use edera_sprout_config::MenuStyle;
use jaarg::{
HelpWriter, HelpWriterContext, Opt, Opts, ParseControl, ParseResult, StandardFullHelpWriter,
};
use log::{info, warn};
use uefi_raw::Status;
/// Default configuration file path.
const DEFAULT_CONFIG_PATH: &str = "\\sprout.toml";
@@ -22,6 +25,12 @@ 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,
/// Makes Sprout follow the Boot Loader Specification and systemd-boot exactly.
pub bls_strict_mode: bool,
}
/// The default Sprout options.
@@ -33,102 +42,155 @@ impl Default for SproutOptions {
boot: None,
force_menu: false,
menu_timeout: None,
menu_style: None,
retain_boot_console: false,
bls_strict_mode: false,
}
}
}
/// The options parser mechanism for Sprout.
impl OptionsRepresentable for SproutOptions {
/// Produce the [SproutOptions] structure.
type Output = Self;
impl SproutOptions {
/// Produces [SproutOptions] from the arguments provided by the UEFI core.
/// Internally, we use the `jaarg` argument parser which has excellent no_std support.
pub fn parse() -> Result<Self> {
enum ArgID {
Help,
AutoConfigure,
Config,
Boot,
ForceMenu,
MenuTimeout,
MenuStyle,
RetainBootConsole,
BlsStrictMode,
}
/// All the Sprout options that are defined.
fn options() -> &'static [(&'static str, OptionDescription<'static>)] {
&[
(
"autoconfigure",
OptionDescription {
description: "Enable Sprout Autoconfiguration",
form: OptionForm::Flag,
},
),
(
"config",
OptionDescription {
description: "Path to Sprout configuration file",
form: OptionForm::Value,
},
),
(
"boot",
OptionDescription {
description: "Entry to boot, bypassing the menu",
form: OptionForm::Value,
},
),
(
"force-menu",
OptionDescription {
description: "Force showing of the boot menu",
form: OptionForm::Flag,
},
),
(
"menu-timeout",
OptionDescription {
description: "Boot menu timeout, in seconds",
form: OptionForm::Value,
},
),
(
"help",
OptionDescription {
description: "Display Sprout Help",
form: OptionForm::Help,
},
),
]
}
// All the options for the Sprout executable.
const OPTIONS: Opts<ArgID> = Opts::new(&[
Opt::help_flag(ArgID::Help, &["--help"]).help_text("Display Sprout Help"),
Opt::flag(ArgID::AutoConfigure, &["--autoconfigure"])
.help_text("Enable Sprout autoconfiguration"),
Opt::value(ArgID::Config, &["--config"], "PATH")
.help_text("Path to Sprout configuration file"),
Opt::value(ArgID::Boot, &["--boot"], "ENTRY")
.help_text("Entry to boot, bypassing the menu"),
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, simple or graphical"),
Opt::flag(ArgID::RetainBootConsole, &["--retain-boot-console"])
.help_text("Retain boot console before boot"),
Opt::flag(ArgID::BlsStrictMode, &["--bls-strict-mode"])
.help_text("Follow the BLS specification and systemd-boot exactly"),
]);
/// Produces [SproutOptions] from the parsed raw `options` map.
fn produce(options: BTreeMap<String, Option<String>>) -> Result<Self> {
// Use the default value of sprout options and have the raw options be parsed into it.
let mut result = Self::default();
// Acquire the arguments as determined by the UEFI core.
let mut args = eficore::env::args()?;
for (key, value) in options {
match key.as_str() {
"autoconfigure" => {
// Enable autoconfiguration.
result.autoconfigure = true;
// 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();
// 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),
"graphical" => result.menu_style = Some(MenuStyle::Graphical),
_ => warn!(
"ignoring invalid {} value '{}': expected basic, simple or graphical",
name, value
),
}
}
ArgID::RetainBootConsole => {
// Retain the boot console before booting.
result.retain_boot_console = true;
}
ArgID::BlsStrictMode => {
// Follow the BLS specification and systemd-boot exactly.
result.bls_strict_mode = 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()
);
}
"config" => {
// The configuration file to load.
result.config = value.context("--config option requires a value")?;
}
"boot" => {
// The entry to boot.
result.boot = Some(value.context("--boot option requires a value")?);
}
"force-menu" => {
// Force showing of the boot menu.
result.force_menu = true;
}
"menu-timeout" => {
// The timeout for the boot menu in seconds.
let value = value.context("--menu-timeout option requires a value")?;
let value = value
.parse::<u64>()
.context("menu-timeout must be a number")?;
result.menu_timeout = Some(value);
}
_ => bail!("unknown option: --{key}"),
}
}
Ok(result)
}
}
-153
View File
@@ -1,153 +0,0 @@
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use anyhow::{Context, Result, bail};
use core::ptr::null_mut;
use eficore::env;
use log::info;
use uefi_raw::Status;
/// The type of option. This disambiguates different behavior
/// of how options are handled.
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub enum OptionForm {
/// A flag, like --verbose.
Flag,
/// A value, in the form --abc 123 or --abc=123.
Value,
/// Help flag, like --help.
Help,
}
/// The description of an option, used in the option parser
/// to make decisions about how to progress.
#[derive(Debug, Clone)]
pub struct OptionDescription<'a> {
/// The description of the option.
pub description: &'a str,
/// The type of option to parse as.
pub form: OptionForm,
}
/// Represents a type that can be parsed from command line arguments.
/// This is a super minimal options parser mechanism just for Sprout.
pub trait OptionsRepresentable {
/// The output type that parsing will produce.
type Output;
/// The configured options for this type. This should describe all the options
/// that are valid to produce the type. The left-hand side is the name of the option,
/// and the right-hand side is the description.
fn options() -> &'static [(&'static str, OptionDescription<'static>)];
/// Produces the type by taking the `options` and processing it into the output.
fn produce(options: BTreeMap<String, Option<String>>) -> Result<Self::Output>;
/// For minimalism, we don't want a full argument parser. Instead, we use
/// a simple --xyz = xyz: None and --abc 123 = abc: Some("123") format.
/// We also support the format: --abc=123
fn parse_raw() -> Result<BTreeMap<String, Option<String>>> {
// Access the configured options for this type.
let configured: BTreeMap<_, _> = BTreeMap::from_iter(Self::options().to_vec());
// Collect all the arguments to Sprout.
// Skip the first argument, which is the path to our executable.
let args = env::args()?;
// Represent options as key-value pairs.
let mut options = BTreeMap::new();
// Iterators makes this way easier.
let mut iterator = args.into_iter().peekable();
loop {
// Consume the next option, if any.
let Some(option) = iterator.next() else {
break;
};
// If the option doesn't start with --, that is invalid.
if !option.starts_with("--") {
bail!("invalid option: {option}");
}
// Strip the -- prefix off.
let mut option = option["--".len()..].trim().to_string();
// An optional value.
let mut value = None;
// Check if the option is of the form --abc=123
if let Some((part_key, part_value)) = option.split_once('=') {
let part_key = part_key.to_string();
let part_value = part_value.to_string();
option = part_key;
value = Some(part_value);
}
// Error on empty option names.
if option.is_empty() {
bail!("invalid empty option");
}
// Find the description of the configured option, if any.
let Some(description) = configured.get(option.as_str()) else {
bail!("invalid option: --{option}");
};
// Check if the option requires a value and error if none was provided.
if description.form == OptionForm::Value && value.is_none() {
// Check for the next value.
let maybe_next = iterator.peek();
// If the next value isn't another option, set the value to the next value.
// Otherwise, it is None.
value = if let Some(next) = maybe_next
&& !next.starts_with("--")
{
iterator.next()
} else {
None
};
}
// If the option form does not support a value and there is a value, error.
if description.form != OptionForm::Value && value.is_some() {
bail!("option --{} does not take a value", option);
}
// Handle the --help flag case.
if description.form == OptionForm::Help {
// Generic configured options output.
info!("Configured Options:");
for (name, description) in &configured {
info!(
" --{}{}: {}",
name,
if description.form == OptionForm::Value {
" <value>"
} else {
""
},
description.description
);
}
// Exit because the help has been displayed.
unsafe {
uefi::boot::exit(uefi::boot::image_handle(), Status::SUCCESS, 0, null_mut());
};
}
// Insert the option and the value into the map.
options.insert(option, value);
}
Ok(options)
}
/// Parses the program arguments as a [Self::Output], calling [Self::parse_raw] and [Self::produce].
fn parse() -> Result<Self::Output> {
// Parse the program arguments into a raw map.
let options = Self::parse_raw().context("unable to parse options")?;
// Produce the options from the map.
Self::produce(options)
}
}
+16 -1
View File
@@ -6,7 +6,7 @@ use anyhow::{Context, Result};
use edera_sprout_config::phases::PhaseConfiguration;
/// Executes the specified [phase] of the boot process.
/// The value [phase] should be a reference of a specific phase in the [PhasesConfiguration].
/// The value [phase] should be a reference of a specific phase in the `PhasesConfiguration`.
/// Any error from the actions is propagated into the [Result] and will interrupt further
/// execution of phase actions.
pub fn phase(context: Rc<SproutContext>, phase: &[PhaseConfiguration]) -> Result<()> {
@@ -24,3 +24,18 @@ pub fn phase(context: Rc<SproutContext>, phase: &[PhaseConfiguration]) -> Result
}
Ok(())
}
/// Manual hook called by code in the bootloader that hands off to another image.
/// This is used to perform actions like clearing the screen.
pub fn before_handoff(context: &SproutContext) -> Result<()> {
// If we have not been asked to retain the boot console, then we should clear the screen.
if !context.root().options().retain_boot_console {
// Clear the screen. We use clear here instead of reset because some firmware,
// particularly Dell firmware, does not clear the screen on reset.
// We clear both stdout and stderr because it's not guaranteed that they are the same
// text output.
uefi::system::with_stdout(|stdout| stdout.clear()).context("unable to clear screen")?;
uefi::system::with_stderr(|stderr| stderr.clear()).context("unable to clear screen")?;
}
Ok(())
}
-26
View File
@@ -1,26 +0,0 @@
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use sha2::{Digest, Sha256};
/// Implements a version comparison algorithm according to the BLS specification.
pub mod vercmp;
/// Combine a sequence of strings into a single string, separated by spaces, ignoring empty strings.
pub fn combine_options<T: AsRef<str>>(options: impl Iterator<Item = T>) -> String {
options
.flat_map(|item| empty_is_none(Some(item)))
.map(|item| item.as_ref().to_string())
.collect::<Vec<_>>()
.join(" ")
}
/// 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 {
hex::encode(Sha256::digest(input.as_bytes()))
}
/// Filter a string-like Option `input` such that an empty string is [None].
pub fn empty_is_none<T: AsRef<str>>(input: Option<T>) -> Option<T> {
input.filter(|input| !input.as_ref().is_empty())
}
-184
View File
@@ -1,184 +0,0 @@
use core::cmp::Ordering;
use core::iter::Peekable;
/// Handles single character advancement and comparison.
macro_rules! handle_single_char {
($ca: expr, $cb:expr, $a_chars:expr, $b_chars:expr, $c:expr) => {
match ($ca == $c, $cb == $c) {
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
(true, true) => {
$a_chars.next();
$b_chars.next();
continue;
}
_ => {}
}
};
}
/// Compares two strings using the BLS version comparison specification.
/// Handles optional values as well by comparing only if both are specified.
pub fn compare_versions_optional(a: Option<&str>, b: Option<&str>) -> Ordering {
match (a, b) {
// If both have values, compare them.
(Some(a), Some(b)) => compare_versions(a, b),
// If the second value is None, then `a` is less than `b`.
(Some(_a), None) => Ordering::Less,
// If the first value is None, the `a` is greater than `b`.
(None, Some(_b)) => Ordering::Greater,
// If both values are None, return that they are equal.
(None, None) => Ordering::Equal,
}
}
/// Compares two strings using the BLS version comparison specification.
/// See: https://uapi-group.org/specifications/specs/version_format_specification/
pub fn compare_versions(a: &str, b: &str) -> Ordering {
// Acquire a peekable iterator for each string.
let mut a_chars = a.chars().peekable();
let mut b_chars = b.chars().peekable();
// Loop until we have reached the end of one of the strings.
loop {
// Skip invalid characters in both strings.
skip_invalid(&mut a_chars);
skip_invalid(&mut b_chars);
// Check if either string has ended.
match (a_chars.peek(), b_chars.peek()) {
// No more characters in either string.
(None, None) => return Ordering::Equal,
// One string has ended, the other hasn't.
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
// Both strings have characters left.
(Some(&ca), Some(&cb)) => {
// Handle the ~ character.
handle_single_char!(ca, cb, a_chars, b_chars, '~');
// Handle '-' character.
handle_single_char!(ca, cb, a_chars, b_chars, '-');
// Handle the '^' character.
handle_single_char!(ca, cb, a_chars, b_chars, '^');
// Handle the '.' character.
handle_single_char!(ca, cb, a_chars, b_chars, '.');
// Handle digits with numerical comparison.
// We key off of the A character being a digit intentionally as we presume
// this indicates it will be the same at this position.
if ca.is_ascii_digit() || cb.is_ascii_digit() {
let result = compare_numeric(&mut a_chars, &mut b_chars);
if result != Ordering::Equal {
return result;
}
continue;
}
// Handle letters with alphabetical comparison.
// We key off of the A character being alphabetical intentionally as we presume
// this indicates it will be the same at this position.
if ca.is_ascii_alphabetic() || cb.is_ascii_alphabetic() {
let result = compare_alphabetic(&mut a_chars, &mut b_chars);
if result != Ordering::Equal {
return result;
}
continue;
}
}
}
}
}
/// Skips characters that are not in the valid character set.
fn skip_invalid<I: Iterator<Item = char>>(iter: &mut Peekable<I>) {
while let Some(&c) = iter.peek() {
if is_valid_char(c) {
break;
}
iter.next();
}
}
/// Checks if a character is in the valid character set for comparison.
fn is_valid_char(c: char) -> bool {
matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '.' | '~' | '^')
}
/// Compares numerical prefixes by extracting numbers.
fn compare_numeric<I: Iterator<Item = char>>(
iter_a: &mut Peekable<I>,
iter_b: &mut Peekable<I>,
) -> Ordering {
let num_a = extract_number(iter_a);
let num_b = extract_number(iter_b);
num_a.cmp(&num_b)
}
/// Extracts a number from the iterator, skipping leading zeros.
fn extract_number<I: Iterator<Item = char>>(iter: &mut Peekable<I>) -> u64 {
// Skip leading zeros
while let Some(&'0') = iter.peek() {
iter.next();
}
let mut num = 0u64;
while let Some(&c) = iter.peek() {
if c.is_ascii_digit() {
iter.next();
num = num.saturating_mul(10).saturating_add(c as u64 - '0' as u64);
} else {
break;
}
}
num
}
/// Compares alphabetical prefixes
/// Capital letters compare lower than lowercase letters (B < a)
fn compare_alphabetic<I: Iterator<Item = char>>(
iter_a: &mut Peekable<I>,
iter_b: &mut Peekable<I>,
) -> Ordering {
loop {
return match (iter_a.peek(), iter_b.peek()) {
(Some(&ca), Some(&cb)) if ca.is_ascii_alphabetic() && cb.is_ascii_alphabetic() => {
if ca == cb {
// Same character, we should continue.
iter_a.next();
iter_b.next();
continue;
}
// Different characters found.
// All capital letters compare lower than lowercase letters.
match (ca.is_ascii_uppercase(), cb.is_ascii_uppercase()) {
(true, false) => Ordering::Less, // uppercase < lowercase
(false, true) => Ordering::Greater, // lowercase > uppercase
(true, true) => ca.cmp(&cb), // both are uppercase
(false, false) => ca.cmp(&cb), // both are lowercase
}
}
(Some(&ca), Some(_)) if ca.is_ascii_alphabetic() => {
// a has letters, b doesn't
Ordering::Greater
}
(Some(_), Some(&cb)) if cb.is_ascii_alphabetic() => {
// b has letters, a doesn't
Ordering::Less
}
(Some(&ca), None) if ca.is_ascii_alphabetic() => Ordering::Greater,
(None, Some(&cb)) if cb.is_ascii_alphabetic() => Ordering::Less,
_ => Ordering::Equal,
};
}
}
+4
View File
@@ -10,3 +10,7 @@ edition.workspace = true
[lib]
name = "edera_sprout_build"
path = "src/lib.rs"
# The logo is only converted on the host, and png does not build for UEFI.
[target.'cfg(not(target_os = "uefi"))'.dependencies]
png.workspace = true
+86
View File
@@ -7,6 +7,14 @@ const SBAT_BLOCK_SIZE: usize = 512;
/// Template contents for the sbat.generated.rs file.
const SBAT_RS_TEMPLATE: &str = include_str!("sbat.template.rs");
/// Template contents for the logo.generated.rs file.
#[cfg(not(target_os = "uefi"))]
const LOGO_RS_TEMPLATE: &str = include_str!("logo.template.rs");
/// The factor that the logo is shrunk by, as the source image is larger than the menu needs.
#[cfg(not(target_os = "uefi"))]
const LOGO_DOWNSCALE: usize = 2;
/// Pad with zeros the given `data` to a multiple of `block_size`.
fn block_pad(data: &mut Vec<u8>, block_size: usize) {
let needed = data.len().div_ceil(block_size).max(1) * block_size;
@@ -74,3 +82,81 @@ pub fn generate_sbat_module() {
// Write the sbat.generated.rs file to the output directory.
fs::write(&rs_file, sbat_rs).expect("unable to write sbat.generated.rs");
}
/// Generate a logo module from the Sprout logo in the assets directory of the workspace.
/// The logo is shrunk by averaging blocks of pixels and is stored as RGBA with premultiplied
/// alpha, which keeps the work done at boot to blending the pixels onto the framebuffer.
/// The output is included by a generated logo.generated.rs file.
#[cfg(not(target_os = "uefi"))]
pub fn generate_logo_module() {
use std::fs::File;
use std::io::BufReader;
// The output directory to place the logo files into.
let output_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set"));
// The path to the root of the crate.
let crate_root =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
// The logo is in the assets directory at the root of the workspace.
let logo_file = crate_root.join("../../assets/logo-small.png");
// Notify Cargo that if the logo changes, we need to regenerate the logo files.
println!(
"cargo:rerun-if-changed={}",
logo_file
.to_str()
.expect("unable to convert logo path file to a string")
);
// Decode the logo as 8-bit RGBA, which is how the logo is stored.
let decoder = png::Decoder::new(BufReader::new(
File::open(&logo_file).expect("unable to open logo"),
));
let mut reader = decoder.read_info().expect("unable to read logo");
let mut source = vec![0; reader.output_buffer_size().expect("logo is too large")];
let info = reader
.next_frame(&mut source)
.expect("unable to decode logo");
assert_eq!(info.color_type, png::ColorType::Rgba, "logo is not RGBA");
assert_eq!(info.bit_depth, png::BitDepth::Eight, "logo is not 8-bit");
// Any pixels on the right and bottom that don't fill a block are dropped.
let width = info.width as usize / LOGO_DOWNSCALE;
let height = info.height as usize / LOGO_DOWNSCALE;
let block = (LOGO_DOWNSCALE * LOGO_DOWNSCALE) as u32;
let mut pixels = Vec::with_capacity(width * height * 4);
for y in 0..height {
for x in 0..width {
// Sum the premultiplied channels of every pixel in the block.
let mut sum = [0u32; 4];
for by in 0..LOGO_DOWNSCALE {
for bx in 0..LOGO_DOWNSCALE {
let index =
((y * LOGO_DOWNSCALE + by) * info.width as usize + x * LOGO_DOWNSCALE + bx)
* 4;
let alpha = source[index + 3] as u32;
for channel in 0..3 {
sum[channel] += source[index + channel] as u32 * alpha / 255;
}
sum[3] += alpha;
}
}
pixels.extend(sum.map(|channel| (channel / block) as u8));
}
}
// Write the logo.rgba file to the output directory.
fs::write(output_dir.join("logo.rgba"), &pixels).expect("unable to write logo.rgba");
// Generate the contents of the logo.generated.rs file.
let logo_rs = LOGO_RS_TEMPLATE
.replace("{width}", &width.to_string())
.replace("{height}", &height.to_string());
// Write the logo.generated.rs file to the output directory.
fs::write(output_dir.join("logo.generated.rs"), logo_rs)
.expect("unable to write logo.generated.rs");
}
+8
View File
@@ -0,0 +1,8 @@
/// The width of the Sprout logo in pixels.
pub const LOGO_WIDTH: usize = {width};
/// The height of the Sprout logo in pixels.
pub const LOGO_HEIGHT: usize = {height};
/// The pixels of the Sprout logo as rows of RGBA bytes with premultiplied alpha.
pub static LOGO: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/logo.rgba"));
+1 -1
View File
@@ -1,5 +1,5 @@
/// Define the SBAT attestation by including the sbat.csv file.
/// See this document for more details: https://github.com/rhboot/shim/blob/main/SBAT.md
/// See this document for more details: <https://github.com/rhboot/shim/blob/main/SBAT.md>
/// NOTE: This data must be aligned by 512 bytes.
#[used]
#[unsafe(link_section = ".sbat")]
+11 -1
View File
@@ -13,9 +13,19 @@ pub struct ChainloadConfiguration {
#[serde(default)]
pub options: Vec<String>,
/// An optional path to a Linux initrd.
/// This uses the [LINUX_EFI_INITRD_MEDIA_GUID] mechanism to load the initrd into the EFI stack.
/// This uses the `LINUX_EFI_INITRD_MEDIA_GUID` mechanism to load the initrd into the EFI stack.
/// For Linux, you can also use initrd=\path\to\initrd as an option, but this option is
/// 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>,
/// An optional path to a flattened devicetree to give to the image.
/// It is installed as the devicetree of the machine until the image returns. It is not
/// used when Secure Boot is enabled, as it can't be verified.
#[serde(default)]
pub devicetree: Option<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>,
+17
View File
@@ -10,4 +10,21 @@ pub struct DriverDeclaration {
/// The filesystem path to the driver.
/// This file should be an EFI executable that can be located and executed.
pub path: String,
/// What the driver provides.
/// This decides which controllers are reconnected after the drivers are loaded.
#[serde(default)]
pub provides: DriverProvides,
}
/// What a driver provides to the firmware.
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum DriverProvides {
/// The driver can provide anything, so every controller is reconnected.
#[default]
#[serde(rename = "any")]
Any,
/// The driver only provides filesystem or disk support,
/// so only the controllers that are disks or partitions are reconnected.
#[serde(rename = "filesystems")]
Filesystems,
}
+3
View File
@@ -18,4 +18,7 @@ pub struct EntryDeclaration {
/// The values to insert into the context when the entry is selected.
#[serde(default)]
pub values: BTreeMap<String, String>,
/// The key to sort entries, via version comparison.
#[serde(default, rename = "sort-key")]
pub sort_key: Option<String>,
}
+32
View File
@@ -1,6 +1,10 @@
use crate::generators::bls::BlsConfiguration;
use crate::generators::list::ListConfiguration;
use crate::generators::matrix::MatrixConfiguration;
use crate::generators::variants::VariantChoice;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
/// Configuration for the BLS generator.
@@ -12,6 +16,9 @@ pub mod list;
/// Configuration for the matrix generator.
pub mod matrix;
/// Configuration for generator variants.
pub mod variants;
/// Declares a generator configuration.
/// Generators allow generating entries at runtime based on a set of data.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
@@ -37,4 +44,29 @@ pub struct GeneratorDeclaration {
/// List generator configuration.
/// Allows you to specify a list of values to generate an entry from.
pub list: Option<ListConfiguration>,
/// Variants to apply to every entry produced by the generator.
/// Each key is an axis, and each axis has a list of choices. Every generated entry
/// is multiplied by every combination of choices, one choice from each axis.
/// For example,
/// variants.console = [{ name = "serial" }, { name = "graphics" }]
/// variants.mode = [{ name = "normal" }, { name = "debug", values.mode-options = "loglevel=7" }]
/// would turn each generated entry into four entries:
/// console = serial, mode = normal
/// console = serial, mode = debug
/// console = graphics, mode = normal
/// console = graphics, mode = debug
/// The axis name is set to the name of the chosen choice, and then the values of the
/// chosen choice are inserted, so `$console` and `$mode-options` can be used by the entry.
/// The entry name is suffixed with the choice names, like `-graphics-debug`.
/// Axes are applied in alphabetical order. Choices are applied in the declared order.
#[serde(default)]
pub variants: BTreeMap<String, Vec<VariantChoice>>,
/// Rules that exclude generated entries, applied after variants.
/// Each rule is a map of value names to glob patterns, where `*` matches anything.
/// An entry is excluded if every value named in any one rule matches its pattern.
/// Values are matched before they are stamped. For example,
/// exclude = [{ version = "0-rescue-*" }, { console = "graphics", mode = "debug" }]
/// excludes BLS rescue entries and the combination of the graphics and debug variants.
#[serde(default)]
pub exclude: Vec<BTreeMap<String, String>>,
}
+123 -1
View File
@@ -1,22 +1,144 @@
use crate::entries::EntryDeclaration;
use alloc::format;
use alloc::string::{String, ToString};
use serde::{Deserialize, Serialize};
/// The default path to the BLS directory.
const BLS_TEMPLATE_PATH: &str = "\\loader";
/// The directory of unified kernel images, relative to the root of the filesystem.
const BLS_UKI_DIRECTORY: &str = "\\EFI\\Linux";
/// The configuration of the BLS generator.
/// The BLS uses the Bootloader Specification to produce
/// entries from an input template.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BlsConfiguration {
/// The entry to use for as a template.
pub entry: EntryDeclaration,
/// The path to the BLS directory.
#[serde(default = "default_bls_path")]
pub path: String,
/// The path to the directory of unified kernel images, which are BLS Type #2 entries.
/// Every `.efi` file in the directory becomes an entry. When this is not set, the
/// directory is `\EFI\Linux` on the same device as `path`. An empty path disables
/// unified kernel images. Two generators that read the same directory produce entries
/// with the same names, so they should not both scan it.
#[serde(default, rename = "uki-path")]
pub uki_path: Option<String>,
/// Whether to also read the Extended Boot Loader Partition, which is the partition with the
/// XBOOTLDR type on the same disk as the partition Sprout was loaded from. Its entries are
/// sorted with the others, and their paths are on that partition, so an action has to use
/// the `$entry-root` value to find them, such as `$entry-root\\$chainload`. Its unified kernel
/// images are always in `\\EFI\\Linux`.
#[serde(default)]
pub xbootldr: bool,
/// Whether generated entries keep the name of the BLS entry file as-is.
/// When enabled, the generator name is not prepended, so the entry names match
/// the BLS entry ids used by tools like `bootctl set-default`. Variants still append
/// their choice names, so entries of a generator with variants never match the ids
/// exactly, and are best selected with a pattern like `<id>-*`. Two BLS generators
/// that read the same BLS directory will produce entries with the same names, so
/// generators that reuse a BLS directory should disable this.
#[serde(default = "default_pin_names", rename = "pin-names")]
pub pin_names: bool,
}
impl BlsConfiguration {
/// The device of the stamped BLS path `bls_path`, which is everything up to the last slash,
/// and which is empty for a path on the partition Sprout was loaded from.
pub fn root_of(bls_path: &str) -> &str {
bls_path.rfind('/').map_or("", |index| &bls_path[..=index])
}
/// The directory of unified kernel images for the stamped BLS path `bls_path`,
/// or None if unified kernel images are disabled.
pub fn uki_path_for(&self, bls_path: &str) -> Option<String> {
match self.uki_path.as_deref() {
Some("") => None,
Some(path) => Some(path.to_string()),
// Keep the device of the BLS path, which is everything up to the last slash.
None => Some(format!("{}{}", Self::root_of(bls_path), BLS_UKI_DIRECTORY)),
}
}
}
/// The default configuration, which matches an empty BLS generator section.
impl Default for BlsConfiguration {
fn default() -> Self {
Self {
entry: Default::default(),
path: default_bls_path(),
uki_path: None,
xbootldr: false,
pin_names: default_pin_names(),
}
}
}
fn default_pin_names() -> bool {
true
}
fn default_bls_path() -> String {
BLS_TEMPLATE_PATH.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_is_the_device_of_the_bls_path() {
assert_eq!(BlsConfiguration::root_of("\\loader"), "");
assert_eq!(
BlsConfiguration::root_of("PciRoot(0x0)/HD(2,GPT,abc)/\\loader"),
"PciRoot(0x0)/HD(2,GPT,abc)/"
);
}
#[test]
fn xbootldr_is_off_by_default() {
assert!(!BlsConfiguration::default().xbootldr);
}
#[test]
fn uki_path_is_next_to_the_default_bls_path() {
let bls = BlsConfiguration::default();
assert_eq!(
bls.uki_path_for("\\loader").as_deref(),
Some("\\EFI\\Linux")
);
}
#[test]
fn uki_path_follows_the_device_of_the_bls_path() {
let bls = BlsConfiguration::default();
assert_eq!(
bls.uki_path_for("PciRoot(0x0)/HD(2,GPT,abc)/\\loader")
.as_deref(),
Some("PciRoot(0x0)/HD(2,GPT,abc)/\\EFI\\Linux")
);
}
#[test]
fn explicit_uki_path_is_used_as_is() {
let bls = BlsConfiguration {
uki_path: Some("\\uki".to_string()),
..Default::default()
};
assert_eq!(
bls.uki_path_for("HD(2,GPT,abc)/\\loader").as_deref(),
Some("\\uki")
);
}
#[test]
fn empty_uki_path_disables_unified_kernel_images() {
let bls = BlsConfiguration {
uki_path: Some(String::new()),
..Default::default()
};
assert_eq!(bls.uki_path_for("\\loader"), None);
}
}
+15
View File
@@ -0,0 +1,15 @@
use alloc::collections::BTreeMap;
use alloc::string::String;
use serde::{Deserialize, Serialize};
/// A single choice of a variant axis.
/// See [crate::generators::GeneratorDeclaration::variants] for how variants are applied.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct VariantChoice {
/// The name of the choice. This is set as the value of the axis name,
/// and is appended to the name of each entry the choice applies to.
pub name: String,
/// The values to insert into the context of each entry the choice applies to.
#[serde(default)]
pub values: BTreeMap<String, String>,
}
+166 -9
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
@@ -38,6 +38,9 @@ pub struct RootConfiguration {
/// Default options for Sprout.
#[serde(default)]
pub options: OptionsConfiguration,
/// Settings for each style of boot menu.
#[serde(rename = "menu-styles", default)]
pub menu_styles: MenuStylesConfiguration,
/// Values to be inserted into the root sprout context.
#[serde(default)]
pub values: BTreeMap<String, String>,
@@ -76,18 +79,115 @@ 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 boot without showing the boot menu.
/// If not specified, a boot menu is shown.
/// The entry to mark as the default entry, instead of the first entry.
#[serde(rename = "default-entry", default)]
pub default_entry: Option<String>,
/// The timeout of the boot menu.
#[serde(rename = "menu-timeout", default = "default_menu_timeout")]
pub menu_timeout: u64,
/// The timeout of the boot menu in seconds. When unset, the timeout comes from the
/// bootloader interface or `loader.conf`, falling back to [DEFAULT_MENU_TIMEOUT_SECONDS].
#[serde(rename = "menu-timeout", default)]
pub menu_timeout: Option<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,
/// Makes Sprout follow the Boot Loader Specification and systemd-boot exactly where it
/// otherwise differs for convenience or safety. For example, the extended boot loader
/// partition is always read, a one-shot entry only sets the default entry, and entries with
/// missing files are hidden. See docs/config.md for everything it changes.
#[serde(rename = "bls-strict-mode", default)]
pub bls_strict_mode: bool,
}
/// Settings for each style of boot menu.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct MenuStylesConfiguration {
/// Settings for the graphical boot menu.
#[serde(default)]
pub graphical: GraphicalMenuConfiguration,
}
/// Settings for the graphical boot menu.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GraphicalMenuConfiguration {
/// Enables selecting entries with the mouse, and shows the cursor. When disabled, or when
/// the firmware has no pointing device, entries are selected with the keyboard only.
#[serde(rename = "enable-mouse", default)]
pub enable_mouse: bool,
/// Shows the Sprout logo next to the name above the entries. When disabled, only the name is
/// shown.
#[serde(rename = "enable-logo", default = "default_true")]
pub enable_logo: bool,
/// Animates the logo, which bounces. When disabled, the logo stays still.
#[serde(rename = "enable-animation", default = "default_true")]
pub enable_animation: bool,
}
/// The default graphical menu settings, which match an empty graphical section.
impl Default for GraphicalMenuConfiguration {
fn default() -> Self {
Self {
enable_mouse: false,
enable_logo: true,
enable_animation: true,
}
}
}
/// The default for settings that are on unless they are turned off.
fn default_true() -> bool {
true
}
/// The default configuration, which matches an empty configuration file.
impl Default for RootConfiguration {
fn default() -> Self {
Self {
version: latest_version(),
options: Default::default(),
menu_styles: 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: None,
menu_style: Default::default(),
autoconfigure: false,
bls_strict_mode: 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,
/// A graphical menu that selects entries with the keyboard or the mouse.
#[serde(rename = "graphical")]
Graphical,
}
/// Get the latest version of the Sprout configuration format.
@@ -95,6 +195,63 @@ pub fn latest_version() -> u32 {
LATEST_VERSION
}
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 graphical_mouse_is_off_by_default() {
assert!(
!RootConfiguration::default()
.menu_styles
.graphical
.enable_mouse
);
assert!(!empty().menu_styles.graphical.enable_mouse);
}
#[test]
fn graphical_logo_and_animation_are_on_by_default() {
let graphical = RootConfiguration::default().menu_styles.graphical;
assert!(graphical.enable_logo);
assert!(graphical.enable_animation);
let graphical = empty().menu_styles.graphical;
assert!(graphical.enable_logo);
assert!(graphical.enable_animation);
}
#[test]
fn bls_strict_mode_is_off_by_default() {
assert!(!RootConfiguration::default().options.bls_strict_mode);
assert!(!empty().options.bls_strict_mode);
}
#[test]
fn menu_timeout_is_unset_by_default() {
assert_eq!(RootConfiguration::default().options.menu_timeout, None);
assert_eq!(empty().options.menu_timeout, None);
}
#[test]
fn default_menu_timeout_is_not_zero() {
assert_ne!(DEFAULT_MENU_TIMEOUT_SECONDS, 0);
}
}
+1 -1
View File
@@ -11,7 +11,6 @@ edition.workspace = true
anyhow.workspace = true
bitflags.workspace = true
log.workspace = true
shlex.workspace = true
spin.workspace = true
uefi.workspace = true
uefi-raw.workspace = true
@@ -19,3 +18,4 @@ uefi-raw.workspace = true
[lib]
name = "eficore"
path = "src/lib.rs"
test = false
+114 -41
View File
@@ -5,6 +5,7 @@ use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use anyhow::{Context, Result};
use log::warn;
use uefi::proto::device_path::DevicePath;
use uefi::{Guid, guid};
use uefi_raw::table::runtime::VariableVendor;
@@ -12,13 +13,16 @@ use uefi_raw::table::runtime::VariableVendor;
/// bitflags: LoaderFeatures bitflags.
mod bitflags;
/// The name of the bootloader to tell the system.
const LOADER_NAME: &str = "Sprout";
/// The name and version of the bootloader to tell the system.
const LOADER_NAME: &str = concat!("Sprout ", env!("CARGO_PKG_VERSION"));
/// 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.
@@ -26,9 +30,20 @@ pub enum BootloaderInterfaceTimeout {
/// Set a timeout for the menu.
Timeout(u64),
/// Timeout is unspecified.
#[default]
Unspecified,
}
/// The timeouts set in the bootloader interface.
/// The one-shot timeout only applies to this boot, so it ranks above everything else.
#[derive(Default)]
pub struct BootloaderInterfaceTimeouts {
/// The timeout from LoaderConfigTimeoutOneShot.
pub oneshot: BootloaderInterfaceTimeout,
/// The timeout from LoaderConfigTimeout.
pub direct: BootloaderInterfaceTimeout,
}
/// Bootloader Interface support.
pub struct BootloaderInterface;
@@ -41,7 +56,6 @@ impl BootloaderInterface {
/// The feature we support in Sprout.
fn features() -> LoaderFeatures {
LoaderFeatures::Xbootldr
| LoaderFeatures::LoadDriver
| LoaderFeatures::Tpm2ActivePcrBanks
| LoaderFeatures::RetainShim
| LoaderFeatures::ConfigTimeout
@@ -49,6 +63,12 @@ impl BootloaderInterface {
| LoaderFeatures::MenuDisable
| LoaderFeatures::EntryDefault
| LoaderFeatures::EntryOneShot
| LoaderFeatures::BootCounting
| LoaderFeatures::SavedEntry
| LoaderFeatures::EntryPreferred
| LoaderFeatures::SortKey
| LoaderFeatures::DeviceTree
| LoaderFeatures::MultiProfileUki
}
/// Tell the system that Sprout was initialized at the current time.
@@ -218,10 +238,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.
@@ -245,53 +264,105 @@ impl BootloaderInterface {
}
// Parse the value as a u64 to decode an numeric value.
let value = value
.parse::<u64>()
.context("unable to parse timeout value")?;
// The specification says that a value of 0 means that the menu should be hidden.
if value == 0 {
return Ok(Some(BootloaderInterfaceTimeout::MenuHidden));
}
// An invalid value is ignored, as a bad variable should not prevent booting.
let value = match value.parse::<u64>() {
Ok(value) => value,
Err(error) => {
warn!("ignoring invalid {} value '{}': {}", key, value, error);
return Ok(None);
}
};
// 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)))
}
/// Get the timeout from the bootloader interface.
/// Get the timeouts from the bootloader interface.
/// This indicates how the menu should behave.
/// If no values are set, Unspecified is returned.
pub fn get_timeout() -> Result<BootloaderInterfaceTimeout> {
/// A timeout that is not set is Unspecified.
pub fn get_timeouts() -> Result<BootloaderInterfaceTimeouts> {
// Attempt to acquire the value of the LoaderConfigTimeoutOneShot variable.
// This should take precedence over the LoaderConfigTimeout variable.
// 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.
let oneshot = Self::get_timeout_value("LoaderConfigTimeoutOneShot", true)
.context("unable to check for LoaderConfigTimeoutOneShot variable")?;
// If oneshot was found, return it.
if let Some(oneshot) = oneshot {
return Ok(oneshot);
}
.context("unable to check for LoaderConfigTimeoutOneShot variable")?
.map(|oneshot| match oneshot {
BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuForce,
BootloaderInterfaceTimeout::Timeout(value) => {
BootloaderInterfaceTimeout::MenuForceTimeout(value)
}
other => other,
})
.unwrap_or_default();
// Attempt to acquire the value of the LoaderConfigTimeout variable.
// This will be used if the LoaderConfigTimeoutOneShot variable is not set.
// The specification says that a value of 0 means that the menu should be hidden.
// A failure to read it is only a warning, as the one-shot variable is already consumed.
let direct = Self::get_timeout_value("LoaderConfigTimeout", false)
.context("unable to check for LoaderConfigTimeout variable")?;
.context("unable to check for LoaderConfigTimeout variable")
.unwrap_or_else(|error| {
warn!("{:#}", error);
None
})
.map(|direct| match direct {
BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuHidden,
other => other,
})
.unwrap_or_default();
// If direct was found, return it.
if let Some(direct) = direct {
return Ok(direct);
}
Ok(BootloaderInterfaceTimeouts { oneshot, direct })
}
// If we reach here, we know that neither variable was set.
// We provide the unspecified value instead.
Ok(BootloaderInterfaceTimeout::Unspecified)
/// Tell the system the path of the entry file whose boot counter was updated, relative
/// to the partition root, so that it can be marked as good once the boot succeeds.
pub fn set_boot_count_path(path: &str) -> Result<()> {
Self::VENDOR.set_cstr16(
"LoaderBootCountPath",
path,
VariableClass::BootAndRuntimeTemporary,
)
}
/// Get the entry that was saved by the previous boot, if any.
pub fn get_last_booted_entry() -> Result<Option<String>> {
Ok(Self::VENDOR
.get_cstr16("LoaderEntryLastBooted")
.context("unable to get last booted entry from bootloader interface")?
.filter(|value| !value.is_empty()))
}
/// Save the entry that is booting, so a later boot can select it again.
pub fn set_last_booted_entry(entry: &str) -> Result<()> {
Self::VENDOR.set_cstr16(
"LoaderEntryLastBooted",
entry,
VariableClass::BootAndRuntimePersistent,
)
}
/// Get the default entry set by the bootloader interface.
pub fn get_default_entry() -> Result<Option<String>> {
Self::VENDOR
// An empty value is treated as if the default entry was not set.
Ok(Self::VENDOR
.get_cstr16("LoaderEntryDefault")
.context("unable to get default entry from bootloader interface")
.context("unable to get default entry from bootloader interface")?
.filter(|value| !value.is_empty()))
}
/// Get the preferred entry set by the bootloader interface.
/// Unlike the default entry, an entry with no boot counter tries left is not used.
pub fn get_preferred_entry() -> Result<Option<String>> {
// An empty value is treated as if the preferred entry was not set.
Ok(Self::VENDOR
.get_cstr16("LoaderEntryPreferred")
.context("unable to get preferred entry from bootloader interface")?
.filter(|value| !value.is_empty()))
}
/// Remove the entry that was saved by a previous boot.
pub fn remove_last_booted_entry() -> Result<()> {
Self::VENDOR.remove("LoaderEntryLastBooted")
}
/// Get the oneshot entry set by the bootloader interface.
@@ -307,11 +378,13 @@ 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.
Ok(Some(value))
// An empty value is treated as if the oneshot entry was not set.
Ok(Some(value).filter(|value| !value.is_empty()))
}
}
@@ -42,5 +42,11 @@ bitflags! {
const Type1UkiUrl = 1 << 17;
/// Bootloader indicates TPM2 active PCR banks.
const Tpm2ActivePcrBanks = 1 << 18;
/// Bootloader supports LoaderEntryPreferred.
const EntryPreferred = 1 << 19;
/// Bootloader supports the keyboard layout.
const KeyboardLayout = 1 << 20;
/// Bootloader measures SMBIOS data.
const SmbiosMeasured = 1 << 21;
}
}
+192
View File
@@ -0,0 +1,192 @@
use crate::handle::find_handle;
use anyhow::{Context, Result, anyhow, bail};
use core::ffi::c_void;
use core::ptr::{self, NonNull};
use log::{info, warn};
use uefi::boot::{AllocateType, MemoryType, PAGE_SIZE};
use uefi::proto::unsafe_protocol;
use uefi_raw::{Guid, Status, guid};
/// The configuration table that holds the flattened devicetree.
const DTB_TABLE_GUID: Guid = guid!("b1b621d5-f19c-41a5-830b-d9152c69aae0");
/// The protocol that firmware such as U-Boot provides to patch a devicetree for the machine.
const DT_FIXUP_GUID: Guid = guid!("e617d64c-fe08-46da-f4dc-bbd5870c7300");
/// The magic number at the start of a flattened devicetree.
const FDT_MAGIC: u32 = 0xd00d_feed;
/// The size of the fixed header of a flattened devicetree.
const FDT_HEADER_SIZE: usize = 7 * 4;
/// The largest devicetree that is accepted. The Linux stub copies it into a smaller buffer.
const MAX_DTB_SIZE: usize = 32 * 1024 * 1024;
/// Apply the fixups for the machine to the devicetree.
const DT_APPLY_FIXUPS: u32 = 0x1;
/// Reserve the memory that the devicetree says to reserve.
const DT_RESERVE_MEMORY: u32 = 0x2;
/// The protocol that patches a devicetree for the machine.
#[unsafe_protocol(DT_FIXUP_GUID)]
#[repr(C)]
struct DtFixupProtocol {
revision: u64,
fixup: unsafe extern "efiapi" fn(
this: *mut DtFixupProtocol,
fdt: *mut c_void,
buffer_size: *mut usize,
flags: u32,
) -> Status,
}
/// A devicetree that is installed for the image that is about to start. When this is dropped,
/// which is when the image returned or failed to start, the devicetree of the firmware is
/// put back and the memory is freed.
pub struct DeviceTree {
/// The pages that hold the devicetree.
pages: NonNull<u8>,
/// The number of pages.
count: usize,
/// The devicetree table of the firmware, which is null if there was none.
original: *const c_void,
/// Whether the table points to the pages.
installed: bool,
}
impl DeviceTree {
/// Check that `dtb` looks like a flattened devicetree that can be installed.
fn validate(dtb: &[u8]) -> Result<()> {
if dtb.len() < FDT_HEADER_SIZE || dtb.len() > MAX_DTB_SIZE {
bail!("the devicetree is {} bytes, which is not valid", dtb.len());
}
// The header is big endian: the magic number, then the total size.
let magic = u32::from_be_bytes([dtb[0], dtb[1], dtb[2], dtb[3]]);
let total = u32::from_be_bytes([dtb[4], dtb[5], dtb[6], dtb[7]]) as usize;
if magic != FDT_MAGIC {
bail!("the file is not a flattened devicetree");
}
if total < FDT_HEADER_SIZE || total > dtb.len() {
bail!("the devicetree has an invalid size");
}
Ok(())
}
/// Allocate `count` pages of memory that belongs to the firmware tables, as a devicetree
/// has to be in memory of the type that ACPI tables are in.
fn allocate(count: usize) -> Result<NonNull<u8>> {
uefi::boot::allocate_pages(AllocateType::AnyPages, MemoryType::ACPI_RECLAIM, count)
.context("unable to allocate memory for the devicetree")
}
/// Install `dtb` as the devicetree of the machine until the returned value is dropped.
pub fn install(dtb: &[u8]) -> Result<Self> {
Self::validate(dtb)?;
// Remember the table of the firmware so it can be put back.
let original = uefi::system::with_config_table(|tables| {
tables
.iter()
.find(|entry| entry.guid == DTB_TABLE_GUID)
.map(|entry| entry.address)
.unwrap_or(ptr::null())
});
let count = dtb.len().div_ceil(PAGE_SIZE);
let pages = Self::allocate(count)?;
// SAFETY: The pages are at least as large as the devicetree, and don't overlap it.
unsafe { ptr::copy_nonoverlapping(dtb.as_ptr(), pages.as_ptr(), dtb.len()) };
let mut tree = Self {
pages,
count,
original,
installed: false,
};
tree.fixup(dtb)?;
// SAFETY: The table points to pages that stay allocated until this is dropped, which
// puts the original table back before the pages are freed.
unsafe {
uefi::boot::install_configuration_table(&DTB_TABLE_GUID, tree.pages.as_ptr().cast())
}
.context("unable to install the devicetree")?;
tree.installed = true;
info!(
"installed the devicetree at {:p} ({} bytes)",
tree.pages.as_ptr(),
dtb.len()
);
Ok(tree)
}
/// Let the firmware patch the devicetree for the machine, if it can.
fn fixup(&mut self, dtb: &[u8]) -> Result<()> {
let Some(handle) = find_handle(&DT_FIXUP_GUID)? else {
return Ok(());
};
// Without the protocol the devicetree is used as it is, as in systemd-boot.
let Ok(mut protocol) = crate::handle::open_shared::<DtFixupProtocol>(handle) else {
warn!("unable to open the devicetree fixup protocol, skipping the fixup");
return Ok(());
};
let fixup = protocol.fixup;
let this: *mut DtFixupProtocol = &mut *protocol;
let flags = DT_APPLY_FIXUPS | DT_RESERVE_MEMORY;
// The firmware gets all the memory that was allocated. If that is not enough, it says
// how much it needs, and it is given a copy of the original in a larger allocation.
let mut size = self.count * PAGE_SIZE;
// SAFETY: The pages are valid for the size that is passed.
let mut status = unsafe { fixup(this, self.pages.as_ptr().cast(), &mut size, flags) };
if status == Status::BUFFER_TOO_SMALL {
let count = size.div_ceil(PAGE_SIZE);
let pages = Self::allocate(count)?;
// SAFETY: The new pages are larger than the devicetree, and don't overlap it.
unsafe { ptr::copy_nonoverlapping(dtb.as_ptr(), pages.as_ptr(), dtb.len()) };
// SAFETY: The old pages came from the same allocator and nothing refers to them yet.
if let Err(error) = unsafe { uefi::boot::free_pages(self.pages, self.count) } {
warn!("unable to free the devicetree memory: {}", error);
}
self.pages = pages;
self.count = count;
size = count * PAGE_SIZE;
// SAFETY: The new pages are valid for the size that is passed.
status = unsafe { fixup(this, self.pages.as_ptr().cast(), &mut size, flags) };
}
if status != Status::SUCCESS {
return Err(anyhow!("the devicetree fixup failed: {:?}", status));
}
Ok(())
}
}
impl Drop for DeviceTree {
fn drop(&mut self) {
if self.installed {
// SAFETY: This puts back the table that the firmware had, which is null if it had
// none, and which removes the table.
let result =
unsafe { uefi::boot::install_configuration_table(&DTB_TABLE_GUID, self.original) };
if let Err(error) = result {
// The table can still point to the pages, so they must not be freed.
if !(error.status() == Status::NOT_FOUND && self.original.is_null()) {
warn!("unable to restore the devicetree: {}", error);
return;
}
}
}
if self.installed {
info!(
"restored the devicetree table of the firmware ({:p})",
self.original
);
}
// SAFETY: Nothing refers to the pages any more.
if let Err(error) = unsafe { uefi::boot::free_pages(self.pages, self.count) } {
warn!("unable to free the devicetree memory: {}", error);
}
}
}
+58 -29
View File
@@ -1,7 +1,7 @@
use alloc::string::{String, ToString};
use alloc::string::String;
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,35 +13,29 @@ 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.
let mut args = shlex::split(&options).unwrap_or_else(|| {
options
.split_ascii_whitespace()
.map(|string| string.to_string())
.collect::<Vec<_>>()
});
// Split the options on whitespace, keeping quoted text together.
// Backslashes are kept as they are, since UEFI paths use them as separators.
let mut args = split_options(&options);
// Correct firmware that may add invalid arguments at the start.
// Witnessed this on a Dell Precision 5690 when direct booting.
@@ -52,7 +46,7 @@ pub fn args() -> Result<Vec<String>> {
.next()
// Filter out unprintable characters and backticks.
// Both of which have been observed in the wild.
.map(|c| c < 0x1f as char || c == '`')
.map(|c| c.is_ascii_control() || c == '`')
.unwrap_or(false)
})
.collect();
@@ -67,3 +61,38 @@ pub fn args() -> Result<Vec<String>> {
Ok(args)
}
/// Splits `options` into arguments on whitespace. Text in single or double quotes is kept together.
fn split_options(options: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_arg = false;
let mut quote = None;
for c in options.chars() {
match quote {
Some(q) if c == q => quote = None,
Some(_) => current.push(c),
None if c == '"' || c == '\'' => {
quote = Some(c);
in_arg = true;
}
None if c.is_whitespace() => {
if in_arg {
args.push(core::mem::take(&mut current));
in_arg = false;
}
}
None => {
current.push(c);
in_arg = true;
}
}
}
if in_arg {
args.push(current);
}
args
}
+92 -1
View File
@@ -1,8 +1,11 @@
use alloc::vec;
use alloc::vec::Vec;
use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use uefi::proto::console::gop::{BltOp, BltPixel, BltRegion, GraphicsOutput};
/// How many unchanged rows may sit between changed rows before they are blitted separately.
const BAND_GAP: usize = 8;
/// Represents the EFI framebuffer.
pub struct Framebuffer {
/// The width of the framebuffer in pixels.
@@ -31,6 +34,30 @@ impl Framebuffer {
})
}
/// The width of the framebuffer in pixels.
pub fn width(&self) -> usize {
self.width
}
/// The height of the framebuffer in pixels.
pub fn height(&self) -> usize {
self.height
}
/// Fill the rectangle at `x` and `y` of `width` and `height` with `color`.
/// The parts of the rectangle outside of the framebuffer are not drawn.
pub fn fill_rect(&mut self, x: usize, y: usize, width: usize, height: usize, color: BltPixel) {
let end_x = x.saturating_add(width).min(self.width);
let end_y = y.saturating_add(height).min(self.height);
if x >= end_x {
return;
}
for row in y..end_y {
// The row and columns are in bounds, so its range is within the pixels.
self.pixels[row * self.width + x..row * self.width + end_x].fill(color);
}
}
/// Mutably acquires a pixel of the framebuffer at the specified `x` and `y` coordinate.
pub fn pixel(&mut self, x: usize, y: usize) -> Option<&mut BltPixel> {
// Verify that the coordinates are within the bounds of the framebuffer.
@@ -44,6 +71,11 @@ impl Framebuffer {
self.pixels.get_mut(index)
}
/// Replace the pixels of the framebuffer with those of `other`, which must be the same size.
pub fn copy_from(&mut self, other: &Framebuffer) {
self.pixels.copy_from_slice(&other.pixels);
}
/// Blit the framebuffer to the specified `gop` [GraphicsOutput].
pub fn blit(&self, gop: &mut GraphicsOutput) -> Result<()> {
gop.blt(BltOp::BufferToVideo {
@@ -55,4 +87,63 @@ impl Framebuffer {
.context("unable to blit framebuffer")?;
Ok(())
}
/// Blit only what is different from `previous`, which must be the same size, to the specified
/// `gop` [GraphicsOutput]. Changes that are far apart vertically are blitted separately, so
/// that the pixels between them are not sent to the display.
pub fn blit_changes(&self, previous: &Framebuffer, gop: &mut GraphicsOutput) -> Result<()> {
if self.width != previous.width || self.height != previous.height {
bail!("framebuffer sizes differ");
}
// The top, bottom, left and right of the changed rows that haven't been blitted yet.
let mut band: Option<(usize, usize, usize, usize)> = None;
let mut quiet = 0;
for row in 0..self.height {
let range = row * self.width..(row + 1) * self.width;
let (now, before) = (&self.pixels[range.clone()], &previous.pixels[range]);
let differs = |(now, before): (&BltPixel, &BltPixel)| {
(now.red, now.green, now.blue) != (before.red, before.green, before.blue)
};
let first = now.iter().zip(before).position(differs);
let Some(first) = first else {
quiet += 1;
if quiet > BAND_GAP
&& let Some(band) = band.take()
{
self.blit_region(gop, band)?;
}
continue;
};
let last = now.iter().zip(before).rposition(differs).unwrap_or(first);
quiet = 0;
band = Some(match band {
Some((top, _, left, right)) => (top, row + 1, left.min(first), right.max(last + 1)),
None => (row, row + 1, first, last + 1),
});
}
if let Some(band) = band {
self.blit_region(gop, band)?;
}
Ok(())
}
/// Blit the region of the `top`, `bottom`, `left` and `right` to the specified `gop`.
fn blit_region(
&self,
gop: &mut GraphicsOutput,
(top, bottom, left, right): (usize, usize, usize, usize),
) -> Result<()> {
gop.blt(BltOp::BufferToVideo {
buffer: &self.pixels,
src: BltRegion::SubRectangle {
coords: (left, top),
px_stride: self.width,
},
dest: (left, top),
dims: (right - left, bottom - top),
})
.context("unable to blit framebuffer region")?;
Ok(())
}
}
+20 -1
View File
@@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use uefi::boot::SearchType;
use uefi::boot::{OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol, SearchType};
use uefi::proto::ProtocolPointer;
use uefi::{Guid, Handle};
use uefi_raw::Status;
@@ -24,3 +25,21 @@ pub fn find_handle(protocol: &Guid) -> Result<Option<Handle>> {
}
}
}
/// Open the protocol `P` on `handle` without taking it from the firmware.
/// An exclusive open makes the firmware disconnect every driver that is using the protocol,
/// which for a disk or a partition would remove the filesystem Sprout is running from.
pub fn open_shared<P: ProtocolPointer + ?Sized>(handle: Handle) -> uefi::Result<ScopedProtocol<P>> {
// SAFETY: The protocols opened this way are only used from this thread, and the firmware
// keeps them installed for as long as they are open.
unsafe {
uefi::boot::open_protocol::<P>(
OpenProtocolParams {
handle,
agent: uefi::boot::image_handle(),
controller: None,
},
OpenProtocolAttributes::GetProtocol,
)
}
}
+11 -2
View File
@@ -1,8 +1,14 @@
//! Sprout EFI Core.
//! This crate provides tools for working with the EFI environment.
//! Sprout EFI core.
//! This crate provides core EFI functionality for Sprout.
// For some reason this triggers, and I can't figure out why.
#![allow(rustdoc::bare_urls)]
#![no_std]
extern crate alloc;
/// Installing a devicetree for the image that is started.
pub mod devicetree;
/// EFI handle helpers.
pub mod handle;
@@ -24,6 +30,9 @@ pub mod platform;
/// Secure Boot support.
pub mod secure;
/// Finding the Extended Boot Loader Partition.
pub mod xbootldr;
/// Support for the shim loader application that enables Secure Boot.
pub mod shim;
+12 -10
View File
@@ -83,16 +83,6 @@ impl ImageLoader {
// If the image loader is installed, we can skip over the security hook.
let requires_security_hook = secure_boot && shim_loaded && !shim_loader_available;
// If the security hook is required, we will bail for now.
if requires_security_hook {
// Install the security hook, if possible. If it's not, this is necessary to continue,
// so we should bail.
let installed = SecurityHook::install().context("unable to install security hook")?;
if !installed {
bail!("unable to install security hook required for this platform");
}
}
// If the shim is loaded, we will need to retain the shim protocol to allow
// loading multiple images.
if shim_loaded {
@@ -114,6 +104,18 @@ impl ImageLoader {
file_path: input.file_path(),
};
// If the security hook is required, install it right before loading the image.
// Installing it here ensures that an error while preparing the image can't
// return without uninstalling the hook.
if requires_security_hook {
// Install the security hook, if possible. If it's not, this is necessary to continue,
// so we should bail.
let installed = SecurityHook::install().context("unable to install security hook")?;
if !installed {
bail!("unable to install security hook required for this platform");
}
}
// Loads the image using Boot Services LoadImage function.
let result = uefi::boot::load_image(current_image, source).context("unable to load image");
+16 -5
View File
@@ -3,6 +3,7 @@ use alloc::vec::Vec;
use anyhow::{Context, Result, bail};
use core::ffi::c_void;
use core::ptr;
use log::error;
use uefi::proto::device_path::DevicePath;
use uefi::proto::device_path::build::DevicePathBuilder;
use uefi::proto::device_path::build::media::Vendor;
@@ -33,8 +34,7 @@ struct MediaLoaderProtocol {
}
/// Represents a media loader which has been registered in the UEFI stack.
/// You MUST call [MediaLoaderHandle::unregister] when ready to unregister.
/// [Drop] is not implemented for this type.
/// Calling `drop` on this handle will unregister the media loader.
pub struct MediaLoaderHandle {
/// The handle of the media loader in the UEFI stack.
handle: Handle,
@@ -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;
}
@@ -255,7 +255,7 @@ impl MediaLoaderHandle {
/// Unregisters a media loader from the UEFI stack.
/// This will free the memory allocated by the passed data.
pub fn unregister(self) -> Result<()> {
fn unregister(&self) -> Result<()> {
// SAFETY: We know that the media loader is registered if the handle is valid,
// so we can safely uninstall it.
// We should have allocated the pointers involved, so we can safely free them.
@@ -293,3 +293,14 @@ impl MediaLoaderHandle {
Ok(())
}
}
/// Implement drop for the handle to automatically unregister the media loader.
impl Drop for MediaLoaderHandle {
fn drop(&mut self) {
// If unregister fails, print an error to the log.
// This may leak stuff, but the only other option is to panic.
if let Err(error) = self.unregister() {
error!("unable to unregister media loader: {}", error);
}
}
}
+33 -45
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};
@@ -34,22 +36,29 @@ impl ResolvedPath {
let mut fs = FileSystem::new(fs);
let path = self
.sub_path
.to_string(DisplayOnly(false), AllowShortcuts(false))?;
.to_string16(DisplayOnly(false), AllowShortcuts(false))?;
let content = fs.read(Path::new(&path));
content.context("unable to read file contents")
}
}
/// 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].
/// Uses the [DevicePathFromText] protocol exclusively, and will fail if it cannot acquire the protocol.
pub fn text_to_device_path(path: &str) -> Result<PoolDevicePath> {
let path = CString16::try_from(path).context("unable to convert path to CString16")?;
pub fn text_to_device_path(path: impl AsRef<str>) -> Result<PoolDevicePath> {
let path = CString16::try_from(path.as_ref()).context("unable to convert path to CString16")?;
let device_path_from_text = uefi::boot::open_protocol_exclusive::<DevicePathFromText>(
uefi::boot::get_handle_for_protocol::<DevicePathFromText>()
.context("no device path from text protocol")?,
@@ -67,20 +76,9 @@ pub fn text_to_device_path(path: &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_string(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_string(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)
}
@@ -113,19 +100,20 @@ pub fn device_path_subpath(path: &DevicePath) -> Result<String> {
/// Resolve a path specified by `input` to its various components.
/// Uses `default_root_path` as the base root if one is not specified in the path.
/// Returns [ResolvedPath] which contains the resolved components.
pub fn resolve_path(default_root_path: Option<&DevicePath>, input: &str) -> Result<ResolvedPath> {
let mut path = text_to_device_path(input).context("unable to convert text to path")?;
pub fn resolve_path(
default_root_path: Option<&DevicePath>,
input: impl ToString,
) -> Result<ResolvedPath> {
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_string(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 {
let mut input = input.to_string();
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
}
+32 -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};
@@ -38,6 +40,15 @@ impl PlatformTpm {
/// The PCR for measuring the bootloader configuration into.
pub const PCR_BOOT_LOADER_CONFIG: PcrIndex = PcrIndex(5);
/// The PCR for measuring the dom0 initrd payload into.
pub const PCR_INITRD: PcrIndex = PcrIndex(9);
/// The PCR for measuring the dom0 kernel payload into.
pub const PCR_KERNEL: PcrIndex = PcrIndex(11);
/// The PCR for measuring kernel command line and xen options into.
pub const PCR_KERNEL_PARAMETERS: PcrIndex = PcrIndex(12);
/// Acquire access to the TPM protocol handle, if possible.
/// Returns None if TPM is not available.
fn protocol() -> Result<Option<TpmProtocolHandle>> {
@@ -112,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(())
}
}
+2 -2
View File
@@ -82,9 +82,9 @@ impl<'a> ShimInput<'a> {
};
// Convert the underlying path to a string.
let path = path
.to_string(DisplayOnly(false), AllowShortcuts(false))
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert device path to string")?;
let path = crate::path::resolve_path(None, &path.to_string())
let path = crate::path::resolve_path(None, path.to_string())
.context("unable to resolve path")?;
// Read the file path.
let data = path.read_file()?;
+88 -49
View File
@@ -2,11 +2,11 @@ use crate::shim::{ShimInput, ShimSupport, ShimVerificationOutput};
use anyhow::{Context, Result};
use core::slice;
use log::warn;
use spin::{Lazy, Mutex};
use spin::{LazyLock, Mutex};
use uefi::proto::device_path::FfiDevicePath;
use uefi::proto::unsafe_protocol;
use uefi::{Guid, guid};
use uefi_raw::Status;
use uefi_raw::{Boolean, Status};
/// GUID for the EFI_SECURITY_ARCH protocol.
const SECURITY_ARCH_GUID: Guid = guid!("a46423e3-4617-49f1-b9ff-d1bfa9115839");
@@ -33,7 +33,7 @@ pub struct SecurityArch2Protocol {
path: *const FfiDevicePath,
file_buffer: *const u8,
file_size: usize,
boot_policy: bool,
boot_policy: Boolean,
) -> Status,
}
@@ -45,7 +45,8 @@ struct SecurityHookState {
/// Global state for the security hook.
/// This is messy, but it is safe given the mutex.
static GLOBAL_HOOK_STATE: Lazy<Mutex<Option<SecurityHookState>>> = Lazy::new(|| Mutex::new(None));
static GLOBAL_HOOK_STATE: LazyLock<Mutex<Option<SecurityHookState>>> =
LazyLock::new(|| Mutex::new(None));
/// Security hook helper.
pub struct SecurityHook;
@@ -81,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.
@@ -99,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
}
@@ -131,21 +175,21 @@ 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,
file_buffer: *const u8,
file_size: usize,
boot_policy: bool,
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 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.
@@ -156,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
}
@@ -215,6 +247,13 @@ impl SecurityHook {
// Acquire the lock to the global state and replace it.
let mut global_state = GLOBAL_HOOK_STATE.lock();
// If the hook is already installed, the protocols contain our hooks.
// Storing them as the original hooks would cause infinite recursion on
// verification failure, so keep the existing state instead.
if global_state.is_some() {
return Ok(true);
}
global_state.replace(state);
// Install the hooks into the UEFI stack.
+10 -2
View File
@@ -10,13 +10,21 @@ pub fn utf16_bytes_to_cstring16(bytes: &[u8]) -> Result<CString16> {
}
// Convert the bytes to UTF-16 data.
let data = bytes
let mut data = bytes
// Chunk everything into two bytes.
.chunks_exact(2)
.as_chunks::<2>()
.0
.iter()
// Reinterpret the bytes as u16 little-endian.
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
// Stop at the first null terminator, if any.
// This ignores any trailing data, such as extra null padding.
.take_while(|c| *c != 0)
// Collect the result into a vector.
.collect::<Vec<_>>();
// Add the null terminator, as the input is not guaranteed to be null-terminated.
data.push(0);
CString16::try_from(data).context("unable to convert utf16 bytes to CString16")
}
+7
View File
@@ -14,6 +14,8 @@ use uefi_raw::table::runtime::{VariableAttributes, VariableVendor};
pub enum VariableClass {
/// The variable is available in Boot Services and Runtime Services and is not persistent.
BootAndRuntimeTemporary,
/// The variable is available in Boot Services and Runtime Services and is persistent.
BootAndRuntimePersistent,
}
impl VariableClass {
@@ -23,6 +25,11 @@ impl VariableClass {
VariableClass::BootAndRuntimeTemporary => {
VariableAttributes::BOOTSERVICE_ACCESS | VariableAttributes::RUNTIME_ACCESS
}
VariableClass::BootAndRuntimePersistent => {
VariableAttributes::BOOTSERVICE_ACCESS
| VariableAttributes::RUNTIME_ACCESS
| VariableAttributes::NON_VOLATILE
}
}
}
}
+82
View File
@@ -0,0 +1,82 @@
use crate::handle::open_shared;
use alloc::boxed::Box;
use alloc::vec::Vec;
use anyhow::{Context, Result};
use uefi::proto::device_path::{DevicePath, DevicePathNode, DeviceSubType, DeviceType};
use uefi::proto::media::fs::SimpleFileSystem;
use uefi::proto::media::partition::PartitionInfo;
use uefi::{Guid, guid};
/// The GPT partition type of the Extended Boot Loader Partition.
/// Reference: <https://uapi-group.org/specifications/specs/boot_loader_specification/>
pub const XBOOTLDR_PARTITION_TYPE: Guid = guid!("bc13c2ff-59e6-4262-a352-b275fd6f7172");
/// Whether `node` is the node of a partition on a hard drive.
fn is_hard_drive_node(node: &DevicePathNode) -> bool {
node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE)
}
/// The device nodes of `path`, without the file path nodes that follow the device.
fn device_nodes(path: &DevicePath) -> Vec<&DevicePathNode> {
path.node_iter()
.filter(|node| node.full_type() != (DeviceType::MEDIA, DeviceSubType::MEDIA_FILE_PATH))
.collect()
}
/// Find the Extended Boot Loader Partition on the same disk as the partition at `path`,
/// which is usually the EFI system partition that Sprout was loaded from. Only a partition
/// that has a filesystem the firmware can read is found. Returns the device path of its
/// filesystem, or None if there is no such partition, such as on a disk without a GPT.
pub fn find_xbootldr(path: &DevicePath) -> Result<Option<Box<DevicePath>>> {
// The partition is the last hard drive node. The disk is everything before it.
let own = device_nodes(path);
let Some(partition_index) = own.iter().rposition(|node| is_hard_drive_node(node)) else {
// There is no partition, such as when booting from an optical disc or the network.
return Ok(None);
};
let disk = &own[..partition_index];
let own_partition = own[partition_index];
let handles =
uefi::boot::find_handles::<SimpleFileSystem>().context("unable to find the filesystems")?;
let mut found: Option<(u32, Box<DevicePath>)> = None;
for handle in handles {
let Ok(candidate_path) = open_shared::<DevicePath>(handle) else {
continue;
};
let nodes = device_nodes(&candidate_path);
// The candidate must be a partition of the same disk, and not the partition itself.
if nodes.len() != disk.len() + 1
|| nodes[..disk.len()] != *disk
|| !is_hard_drive_node(nodes[disk.len()])
|| nodes[disk.len()] == own_partition
{
continue;
}
// The partition type is only available for a GPT partition.
let Ok(info) = open_shared::<PartitionInfo>(handle) else {
continue;
};
let Some(entry) = info.gpt_partition_entry() else {
continue;
};
// The entry is packed, so the field is copied before it is compared.
let partition_type = entry.partition_type_guid.0;
if partition_type != XBOOTLDR_PARTITION_TYPE {
continue;
}
// There should only be one, but if there are several, use the first partition.
let number = <&uefi::proto::device_path::media::HardDrive>::try_from(nodes[disk.len()])
.map(|drive| drive.partition_number())
.unwrap_or(u32::MAX);
if found.as_ref().is_none_or(|(best, _)| number < *best) {
found = Some((number, candidate_path.to_boxed()));
}
}
Ok(found.map(|(_, path)| path))
}
+19
View File
@@ -0,0 +1,19 @@
# This crate explicitly does not have uefi/uefi-raw dependencies,
# so that the contents can be unit-testable on non-UEFI target hosts.
# Do not add uefi or uefi-raw as dependencies.
[package]
name = "edera-sprout-parsing"
description = "Sprout Parsing Utilities (UEFI-free)"
license.workspace = true
version.workspace = true
homepage.workspace = true
repository.workspace = true
edition.workspace = true
[dependencies]
hex.workspace = true
sha2.workspace = true
[lib]
name = "edera_sprout_parsing"
path = "src/lib.rs"
+844
View File
@@ -0,0 +1,844 @@
#![no_std]
extern crate alloc;
use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::cmp::Reverse;
use sha2::{Digest, Sha256};
/// Stamps the `text` value with the specified `values` map. The returned value indicates
/// whether the `text` has been changed and the value that was stamped and changed.
///
/// Stamping works like this:
/// - Start with the input text.
/// - Sort all the keys in reverse length order (longest keys first)
/// - For each key, if the key is not empty, replace $KEY in the text.
/// - Each follow-up iteration acts upon the last iterations result.
/// - We keep track if the text changes during the replacement.
/// - We return both whether the text changed during any iteration and the final result.
pub fn stamp_values(values: &BTreeMap<String, String>, text: impl AsRef<str>) -> (bool, String) {
let mut result = text.as_ref().to_string();
let mut did_change = false;
// Sort the keys by length. This is to ensure that we stamp the longest keys first.
// If we did not do this, "$abc" could be stamped by "$a" into an invalid result.
let mut keys = values.keys().collect::<Vec<_>>();
// Sort by key length, reversed. This results in the longest keys appearing first.
keys.sort_by_key(|key| Reverse(key.len()));
for key in keys {
// Empty keys are not supported.
if key.is_empty() {
continue;
}
// We can fetch the value from the map. It is verifiable that the key exists.
let Some(value) = values.get(key) else {
unreachable!("keys iterated over is collected on a map that cannot be modified");
};
let next_result = result.replace(&format!("${key}"), value);
if result != next_result {
did_change = true;
}
result = next_result;
}
(did_change, result)
}
/// Builds out multiple generations of `input` based on a matrix style.
/// For example, if input is: {"x": ["a", "b"], "y": ["c", "d"]}
/// It will produce:
/// x: a, y: c
/// x: a, y: d
/// x: b, y: c
/// x: b, y: d
pub fn build_matrix(input: &BTreeMap<String, Vec<String>>) -> Vec<BTreeMap<String, String>> {
// Convert the input into a vector of tuples.
let items: Vec<(String, Vec<String>)> = input.clone().into_iter().collect();
// The result is a vector of maps.
let mut result: Vec<BTreeMap<String, String>> = alloc::vec![BTreeMap::new()];
for (key, values) in items {
let mut new_result = Vec::new();
// Produce all the combinations of the input values.
for combination in &result {
for value in &values {
let mut new_combination = combination.clone();
new_combination.insert(key.clone(), value.clone());
new_result.push(new_combination);
}
}
result = new_result;
}
result.into_iter().filter(|item| !item.is_empty()).collect()
}
/// Combine a sequence of strings into a single string, separated by spaces, ignoring empty strings.
pub fn combine_options<T: AsRef<str>>(options: impl Iterator<Item = T>) -> String {
options
.flat_map(|item| empty_is_none(Some(item)))
.map(|item| item.as_ref().to_string())
.collect::<Vec<_>>()
.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 {
hex::encode(Sha256::digest(input.as_bytes()))
}
/// Filter a string-like Option `input` such that an empty string is [None].
pub fn empty_is_none<T: AsRef<str>>(input: Option<T>) -> Option<T> {
input.filter(|input| !input.as_ref().is_empty())
}
/// Build a Xen EFI stub configuration file from pre-stamped `xen_options` and `kernel_options`.
/// The returned string is in the Xen ini-like config file format.
pub fn build_xen_config(xen_options: &str, kernel_options: &str) -> String {
[
// global section
"[global]",
// default configuration section
"default=sprout",
// configuration section for sprout
"[sprout]",
// xen options
&format!("options={}", xen_options),
// kernel options, stub replaces the kernel path
// the kernel is provided via media loader
&format!("kernel=stub {}", kernel_options),
// required or else the last line will be ignored
"",
]
.join("\n")
}
/// Filename prefixes used to identify Linux kernel images.
/// These must be lowercase, as file names are lowercased before matching.
pub const LINUX_KERNEL_PREFIXES: &[&str] = &["vmlinuz", "image"];
/// Filename prefixes used to identify initramfs images paired with a kernel.
pub const LINUX_INITRAMFS_PREFIXES: &[&str] = &["initramfs", "initrd", "initrd.img"];
/// Check whether `name` (already lowercased) matches one of the `kernel_prefixes`,
/// either exactly or as a dash-separated prefix (e.g. `"vmlinuz-6.1"`).
/// Returns the matched prefix string if found.
pub fn match_kernel_prefix<'a>(name: &str, kernel_prefixes: &[&'a str]) -> Option<&'a str> {
kernel_prefixes
.iter()
.find(|prefix| name == **prefix || name.starts_with(&format!("{}-", prefix)))
.copied()
}
/// Generate initramfs candidate filenames by combining each entry of `initramfs_prefixes`
/// 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()
}
/// Matches `text` against a glob `pattern` where `*` matches any sequence of characters,
/// including an empty one. Every other character matches itself exactly.
/// A pattern without any `*` only matches `text` when they are equal.
pub fn glob_match(pattern: &str, text: &str) -> bool {
let pattern = pattern.as_bytes();
let text = text.as_bytes();
// The current position in the pattern and the text.
let (mut p, mut t) = (0, 0);
// The position of the last `*` in the pattern and the text position it was tried at.
let mut backtrack: Option<(usize, usize)> = None;
while t < text.len() {
if p < pattern.len() && pattern[p] == b'*' {
// Try matching the `*` against nothing first, remembering where to resume.
backtrack = Some((p, t));
p += 1;
} else if p < pattern.len() && pattern[p] == text[t] {
p += 1;
t += 1;
} else if let Some((star, star_text)) = backtrack {
// Let the last `*` consume one more character and try again.
p = star + 1;
t = star_text + 1;
backtrack = Some((star, star_text + 1));
} else {
return false;
}
}
// Any remaining pattern must be made of `*` only.
pattern[p..].iter().all(|item| *item == b'*')
}
/// One piece of a pattern for [fnmatch_ignore_case].
enum FnmatchToken {
/// A byte that matches itself, already lowercased.
Literal(u8),
/// `?`, which matches any one byte.
Any,
/// `*`, which matches any sequence of bytes.
Star,
/// `[...]`, which matches a byte in any of the inclusive ranges, already lowercased.
Class(Vec<(u8, u8)>),
/// A pattern that can't match anything, such as one with an unterminated `[`.
Never,
}
/// Splits `pattern` into tokens. An unterminated `[` never matches, as in systemd-boot.
fn fnmatch_tokens(pattern: &[u8]) -> Vec<FnmatchToken> {
let mut tokens = Vec::new();
let mut index = 0;
while index < pattern.len() {
let byte = pattern[index];
index += 1;
match byte {
b'*' => tokens.push(FnmatchToken::Star),
b'?' => tokens.push(FnmatchToken::Any),
b'\\' if index < pattern.len() => {
tokens.push(FnmatchToken::Literal(pattern[index].to_ascii_lowercase()));
index += 1;
}
b'[' => match pattern[index..].iter().position(|item| *item == b']') {
Some(length) if length > 0 => {
let class = &pattern[index..index + length];
let mut ranges = Vec::new();
let mut position = 0;
while position < class.len() {
let low = class[position].to_ascii_lowercase();
if position + 2 < class.len() && class[position + 1] == b'-' {
ranges.push((low, class[position + 2].to_ascii_lowercase()));
position += 3;
} else {
ranges.push((low, low));
position += 1;
}
}
tokens.push(FnmatchToken::Class(ranges));
index += length + 1;
}
_ => tokens.push(FnmatchToken::Never),
},
other => tokens.push(FnmatchToken::Literal(other.to_ascii_lowercase())),
}
}
tokens
}
/// Matches `text` against `pattern` the way systemd-boot matches entry ids: ignoring ASCII case,
/// with `*` for any sequence, `?` for any one character, `[a-z]` sets and ranges, and `\` to
/// escape the next character. Unlike [glob_match], this is for names that come from outside of
/// the Sprout configuration, such as EFI variables and loader.conf.
pub fn fnmatch_ignore_case(pattern: &str, text: &str) -> bool {
let tokens = fnmatch_tokens(pattern.as_bytes());
let text = text.as_bytes();
let (mut t, mut p) = (0, 0);
let mut backtrack: Option<(usize, usize)> = None;
while t < text.len() {
let byte = text[t].to_ascii_lowercase();
let matched = match tokens.get(p) {
Some(FnmatchToken::Literal(literal)) => *literal == byte,
Some(FnmatchToken::Any) => true,
Some(FnmatchToken::Class(ranges)) => ranges
.iter()
.any(|(low, high)| (*low..=*high).contains(&byte)),
Some(FnmatchToken::Star) => {
// Try matching the `*` against nothing first, remembering where to resume.
backtrack = Some((p, t));
p += 1;
continue;
}
Some(FnmatchToken::Never) | None => false,
};
if matched {
p += 1;
t += 1;
} else if let Some((star, star_text)) = backtrack {
// Let the last `*` consume one more character and try again.
p = star + 1;
t = star_text + 1;
backtrack = Some((star, star_text + 1));
} else {
return false;
}
}
// Any remaining pattern must be made of `*` only.
tokens[p..]
.iter()
.all(|token| matches!(token, FnmatchToken::Star))
}
/// Determines whether every key in `rule` is present in `values` with a value
/// that matches the glob pattern from `rule`. An empty rule never matches,
/// so that an empty rule cannot accidentally match everything.
pub fn rule_matches(rule: &BTreeMap<String, String>, values: &BTreeMap<String, String>) -> bool {
!rule.is_empty()
&& rule.iter().all(|(key, pattern)| {
values
.get(key)
.is_some_and(|value| glob_match(pattern, value))
})
}
/// A single combination of variant choices produced by [build_variants].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VariantCombination {
/// The names of the chosen choices, in axis order.
pub names: Vec<String>,
/// The values to insert into the context for this combination.
/// Each axis name is set to the name of its chosen choice, and then
/// the values of each chosen choice are inserted.
pub values: BTreeMap<String, String>,
}
/// A variant axis, which is a name and a list of choices.
/// Each choice is a name and the values it inserts.
pub type VariantAxis = (String, Vec<(String, BTreeMap<String, String>)>);
/// Builds every combination of the variant `axes`.
/// Each axis is a name and a list of choices, where each choice is a name and a set of values.
/// Combinations are produced in axis order, with the last axis varying fastest, and the
/// choices of each axis in the order they are declared.
/// No axes produce a single empty combination. An axis without any choices produces nothing.
pub fn build_variants(axes: &[VariantAxis]) -> Vec<VariantCombination> {
let mut result = alloc::vec![VariantCombination {
names: Vec::new(),
values: BTreeMap::new(),
}];
for (axis, choices) in axes {
let mut next = Vec::new();
for combination in &result {
for (name, values) in choices {
let mut combination = combination.clone();
combination.names.push(name.clone());
combination.values.insert(axis.clone(), name.clone());
combination
.values
.extend(values.iter().map(|(k, v)| (k.clone(), v.clone())));
next.push(combination);
}
}
result = next;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn stamp_replaces_known_key() {
let values = map(&[("name", "world")]);
let (changed, result) = stamp_values(&values, "hello $name");
assert!(changed);
assert_eq!(result, "hello world");
}
#[test]
fn stamp_no_match_returns_unchanged() {
let values = map(&[("name", "world")]);
let (changed, result) = stamp_values(&values, "hello there");
assert!(!changed);
assert_eq!(result, "hello there");
}
#[test]
fn stamp_longer_key_takes_precedence_over_shorter() {
// Without longest-first ordering, "$ab" would be partially matched by "$a"
let values = map(&[("a", "WRONG"), ("ab", "RIGHT")]);
let (changed, result) = stamp_values(&values, "$ab");
assert!(changed);
assert_eq!(result, "RIGHT");
}
#[test]
fn stamp_empty_key_is_skipped() {
let values = map(&[("", "should-not-appear"), ("x", "val")]);
let (_, result) = stamp_values(&values, "$x");
assert_eq!(result, "val");
assert!(!result.contains("should-not-appear"));
}
#[test]
fn stamp_multiple_keys_replaced() {
let values = map(&[("a", "foo"), ("b", "bar")]);
let (changed, result) = stamp_values(&values, "$a and $b");
assert!(changed);
assert_eq!(result, "foo and bar");
}
#[test]
fn stamp_empty_text_returns_empty() {
let values = map(&[("a", "foo")]);
let (changed, result) = stamp_values(&values, "");
assert!(!changed);
assert_eq!(result, "");
}
#[test]
fn stamp_empty_map_returns_unchanged() {
let values = map(&[]);
let (changed, result) = stamp_values(&values, "hello $name");
assert!(!changed);
assert_eq!(result, "hello $name");
}
fn matrix_map(pairs: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
pairs
.iter()
.map(|(k, vs)| (k.to_string(), vs.iter().map(|v| v.to_string()).collect()))
.collect()
}
#[test]
fn matrix_single_key_produces_one_entry_per_value() {
let input = matrix_map(&[("x", &["a", "b", "c"])]);
let result = build_matrix(&input);
assert_eq!(result.len(), 3);
}
#[test]
fn matrix_two_keys_produces_cartesian_product() {
let input = matrix_map(&[("x", &["a", "b"]), ("y", &["c", "d"])]);
let result = build_matrix(&input);
assert_eq!(result.len(), 4);
// Every combination of x and y should be present.
for x in &["a", "b"] {
for y in &["c", "d"] {
assert!(
result
.iter()
.any(|m| m.get("x").map(|s| s.as_str()) == Some(x)
&& m.get("y").map(|s| s.as_str()) == Some(y))
);
}
}
}
#[test]
fn matrix_empty_input_produces_no_entries() {
let input = matrix_map(&[]);
let result = build_matrix(&input);
assert!(result.is_empty());
}
#[test]
fn matrix_key_with_empty_values_produces_no_entries() {
let input = matrix_map(&[("x", &[])]);
let result = build_matrix(&input);
assert!(result.is_empty());
}
#[test]
fn combine_options_joins_with_space() {
let result = combine_options(["a", "b", "c"].iter().copied());
assert_eq!(result, "a b c");
}
#[test]
fn combine_options_skips_empty_strings() {
let result = combine_options(["a", "", "b"].iter().copied());
assert_eq!(result, "a b");
}
#[test]
fn combine_options_all_empty_returns_empty() {
let result = combine_options(["", ""].iter().copied());
assert_eq!(result, "");
}
#[test]
fn combine_options_empty_iterator_returns_empty() {
let result = combine_options(core::iter::empty::<&str>());
assert_eq!(result, "");
}
#[test]
fn empty_is_none_returns_none_for_empty_string() {
assert!(empty_is_none(Some("")).is_none());
}
#[test]
fn empty_is_none_returns_some_for_nonempty_string() {
assert_eq!(empty_is_none(Some("x")), Some("x"));
}
#[test]
fn empty_is_none_passthrough_on_none() {
assert!(empty_is_none(None::<&str>).is_none());
}
#[test]
fn unique_hash_is_deterministic() {
assert_eq!(unique_hash("hello"), unique_hash("hello"));
}
#[test]
fn unique_hash_differs_for_different_inputs() {
assert_ne!(unique_hash("hello"), unique_hash("world"));
}
#[test]
fn unique_hash_empty_string() {
// SHA-256 of "" is well-known; just check it produces a non-empty hex string.
let h = unique_hash("");
assert!(!h.is_empty());
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn xen_config_contains_global_and_sprout_sections() {
let config = build_xen_config("", "");
assert!(config.contains("[global]"));
assert!(config.contains("[sprout]"));
assert!(config.contains("default=sprout"));
}
#[test]
fn xen_config_embeds_xen_options() {
let config = build_xen_config("--no-real-mode --iommu=no", "");
assert!(config.contains("options=--no-real-mode --iommu=no"));
}
#[test]
fn xen_config_embeds_kernel_options() {
let config = build_xen_config("", "quiet splash");
assert!(config.contains("kernel=stub quiet splash"));
}
#[test]
fn xen_config_ends_with_newline() {
// Required or the last line will be ignored by the Xen config parser.
let config = build_xen_config("", "");
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!(
match_kernel_prefix("vmlinuz", LINUX_KERNEL_PREFIXES),
Some("vmlinuz")
);
}
#[test]
fn kernel_prefix_dash_suffix_match() {
assert_eq!(
match_kernel_prefix("vmlinuz-6.1.0", LINUX_KERNEL_PREFIXES),
Some("vmlinuz")
);
}
#[test]
fn kernel_prefix_matches_lowercased_arm64_image() {
// Callers lowercase file names before matching, so "Image-6.1" arrives as "image-6.1".
assert_eq!(
match_kernel_prefix("image-6.1", LINUX_KERNEL_PREFIXES),
Some("image")
);
assert_eq!(
match_kernel_prefix("image", LINUX_KERNEL_PREFIXES),
Some("image")
);
}
#[test]
fn kernel_prefix_case_sensitive_no_match() {
// match_kernel_prefix expects the caller to lowercase first; uppercase input won't match.
assert!(match_kernel_prefix("VMLINUZ-6.1", LINUX_KERNEL_PREFIXES).is_none());
}
#[test]
fn kernel_prefix_no_match() {
assert!(match_kernel_prefix("initramfs-6.1", LINUX_KERNEL_PREFIXES).is_none());
}
#[test]
fn kernel_prefix_partial_no_match() {
// "vmlinuz6.1" has no dash separator — should not match.
assert!(match_kernel_prefix("vmlinuz6.1", LINUX_KERNEL_PREFIXES).is_none());
}
#[test]
fn initramfs_candidates_with_suffix() {
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.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",
"initramfs.img",
"initrd.img.img"
]
);
}
#[test]
fn initramfs_candidates_empty_prefixes() {
let candidates: Vec<_> = initramfs_candidates("-6.1.0", &[]).collect();
assert!(candidates.is_empty());
}
#[test]
fn fnmatch_ignores_ascii_case() {
assert!(fnmatch_ignore_case("Foo.CONF", "foo.conf"));
assert!(fnmatch_ignore_case("fedora-*", "Fedora-6.5.conf"));
assert!(!fnmatch_ignore_case("fedora", "fedora.conf"));
}
#[test]
fn fnmatch_question_mark_matches_one_character() {
assert!(fnmatch_ignore_case("a?c", "abc"));
assert!(!fnmatch_ignore_case("a?c", "ac"));
assert!(!fnmatch_ignore_case("a?c", "abbc"));
}
#[test]
fn fnmatch_brackets_match_sets_and_ranges() {
assert!(fnmatch_ignore_case("linux-[0-9].conf", "linux-7.conf"));
assert!(!fnmatch_ignore_case("linux-[0-9].conf", "linux-x.conf"));
assert!(fnmatch_ignore_case("[ab]c", "bc"));
assert!(!fnmatch_ignore_case("[ab]c", "cc"));
assert!(fnmatch_ignore_case("[A-C]x", "bx"));
}
#[test]
fn fnmatch_backslash_escapes_the_next_character() {
assert!(fnmatch_ignore_case("a\\*b", "a*b"));
assert!(!fnmatch_ignore_case("a\\*b", "aXb"));
assert!(fnmatch_ignore_case("a\\?", "a?"));
}
#[test]
fn fnmatch_star_backtracks() {
assert!(fnmatch_ignore_case("*-*-1.conf", "a-b-c-1.conf"));
assert!(fnmatch_ignore_case("*", ""));
assert!(!fnmatch_ignore_case("*x", "abc"));
}
#[test]
fn fnmatch_unterminated_bracket_matches_nothing() {
assert!(!fnmatch_ignore_case("a[b", "a[b"));
assert!(!fnmatch_ignore_case("a[b", "ab"));
assert!(!fnmatch_ignore_case("a[", "a["));
}
#[test]
fn fnmatch_empty_pattern_only_matches_empty_text() {
assert!(fnmatch_ignore_case("", ""));
assert!(!fnmatch_ignore_case("", "a"));
}
#[test]
fn glob_without_star_is_exact() {
assert!(glob_match("abc", "abc"));
assert!(!glob_match("abc", "abcd"));
assert!(!glob_match("abc", "ab"));
assert!(glob_match("", ""));
assert!(!glob_match("", "a"));
}
#[test]
fn glob_trailing_star_is_prefix() {
assert!(glob_match("fedora-*", "fedora-6.1"));
assert!(glob_match("fedora-*", "fedora-"));
assert!(!glob_match("fedora-*", "linux-fedora-6.1"));
}
#[test]
fn glob_star_anywhere() {
assert!(glob_match(
"linux-*-graphics-debug",
"linux-abc-6.1-graphics-debug"
));
assert!(!glob_match(
"linux-*-graphics-debug",
"linux-abc-6.1-serial-debug"
));
assert!(glob_match("*rescue*", "0-rescue-abc"));
assert!(glob_match("*", ""));
assert!(glob_match("a**b", "ab"));
assert!(glob_match("*a*a*", "banana"));
assert!(!glob_match("*a*a*a*a*", "banana"));
}
#[test]
fn rule_requires_all_keys() {
let values = map(&[("console", "graphics"), ("mode", "debug")]);
assert!(rule_matches(
&map(&[("console", "graphics"), ("mode", "deb*")]),
&values
));
assert!(!rule_matches(
&map(&[("console", "graphics"), ("mode", "normal")]),
&values
));
assert!(!rule_matches(&map(&[("missing", "*")]), &values));
assert!(!rule_matches(&BTreeMap::new(), &values));
}
fn axis(name: &str, choices: &[(&str, &[(&str, &str)])]) -> VariantAxis {
(
name.to_string(),
choices
.iter()
.map(|(choice, values)| (choice.to_string(), map(values)))
.collect(),
)
}
#[test]
fn variants_without_axes_produce_one_empty_combination() {
let result = build_variants(&[]);
assert_eq!(result.len(), 1);
assert!(result[0].names.is_empty());
assert!(result[0].values.is_empty());
}
#[test]
fn variants_with_empty_axis_produce_nothing() {
let result = build_variants(&[axis("a", &[("x", &[])]), axis("b", &[])]);
assert!(result.is_empty());
}
#[test]
fn variants_are_ordered_and_set_values() {
let result = build_variants(&[
axis(
"console",
&[
("serial", &[("console-title", "Serial")]),
("graphics", &[("console-title", "Graphics")]),
],
),
axis(
"mode",
&[("normal", &[]), ("debug", &[("mode-title", " Debug")])],
),
]);
let names: Vec<String> = result.iter().map(|c| c.names.join("-")).collect();
assert_eq!(
names,
[
"serial-normal",
"serial-debug",
"graphics-normal",
"graphics-debug"
]
);
assert_eq!(
result[3].values,
map(&[
("console", "graphics"),
("console-title", "Graphics"),
("mode", "debug"),
("mode-title", " Debug")
])
);
}
#[test]
fn variant_choice_values_override_axis_name() {
let result = build_variants(&[axis("console", &[("serial", &[("console", "custom")])])]);
assert_eq!(
result[0].values.get("console").map(String::as_str),
Some("custom")
);
}
}
+586
View File
@@ -0,0 +1,586 @@
# Configuring Sprout
Sprout reads a TOML file, `\sprout.toml`, from the root of the EFI partition it was loaded from. This page
covers everything that file can hold, the command line options that override it, and the BLS behavior that
sits next to it. For installing Sprout, see the [setup guides](./setup).
- [Command line options](#command-line-options)
- [The file at a glance](#the-file-at-a-glance)
- [Values](#values)
- [`options`](#options)
- [`menu-styles`](#menu-styles)
- [`drivers`](#drivers)
- [`extractors`](#extractors)
- [`actions`](#actions)
- [`entries`](#entries)
- [`generators`](#generators)
- [`phases`](#phases)
- [Autoconfiguration](#autoconfiguration)
- [Bootloader Specification support](#bootloader-specification-support)
- [Strict mode](#strict-mode)
Sprout reads the file once at startup. A key it doesn't know is ignored with a warning that names the key, so
a typo shows up in the boot log instead of silently doing nothing. A `version` other than `1` stops Sprout.
## Command line options
These are passed to `sprout.efi` as load options, which is how GRUB or `efibootmgr` hand them over. They
override the matching setting in the file.
| Option | What it does |
|---------------------------|-----------------------------------------------------------------------------|
| `--config=PATH` | Load a configuration file other than `\sprout.toml`. |
| `--autoconfigure` | Skip the configuration file and build one from what Sprout finds. |
| `--boot=ENTRY` | Boot an entry without showing the menu. See [entries](#entries) for matching. |
| `--force-menu` | Show the menu even when `--boot` picked an entry. |
| `--menu-style=STYLE` | `basic`, `simple` or `graphical`. |
| `--menu-timeout=SECONDS` | How long the menu waits before booting the default entry. |
| `--retain-boot-console` | Don't clear the screen before handing off to the entry. |
| `--bls-strict-mode` | Turn on [strict mode](#strict-mode). |
| `--help` | Print the options. |
```bash
# Boot a specific configuration file.
$ sprout.efi --config=\path\to\config.toml
# Boot "Boot Xen" at once, but only show the menu if it can't be found.
$ sprout.efi --boot="Boot Xen"
# Show the graphical menu for 10 seconds.
$ sprout.efi --menu-style=graphical --menu-timeout=10
```
An argument Sprout can't parse is dropped with a warning and the rest are used, since a typo in a boot
entry shouldn't leave a machine unbootable.
## The file at a glance
Every section is optional. An empty file is a valid configuration that has no entries.
```toml
version = 1
[options] # settings for Sprout itself
[menu-styles.x] # settings for a style of menu
[values] # named strings for use anywhere with $name
[drivers.x] # EFI drivers to load
[extractors.x] # values worked out at boot
[actions.x] # things Sprout can do
[entries.x] # items in the boot menu
[generators.x] # makes entries from data
[[phases.early]] # actions to run at set points
```
The pieces fit together like this: an **entry** is a menu item that runs **actions**; an action is configured
with strings that can contain **values**; values come from the `[values]` table, from **extractors**, and
from **generators**. **Drivers** run first so the filesystems the rest of the file points at exist.
Order at boot:
1. `phases.early`
2. drivers load
3. autoconfiguration, if enabled
4. extractors run
5. `phases.startup`
6. entries and generators produce the menu
7. an entry is chosen
8. `phases.late`, then the entry's actions
## Values
A value is a named string. Anywhere a string is accepted in an entry, action, driver path or phase, `$name`
is replaced with the value called `name`.
```toml
[values]
root-options = "root=/dev/sda2 ro"
kernel = "\\vmlinuz"
[actions.boot]
chainload.path = "$kernel"
chainload.options = ["$root-options", "quiet"]
```
Some details that matter in practice:
- Values can refer to other values. Sprout keeps substituting until nothing changes, so
`a = "$b"`, `b = "x"` gives `x` for both. A value that never settles, such as one that contains itself,
is an error once it hits an iteration or length limit.
- When two keys share a prefix, the longer one wins, so `$kernel-options` is never read as `$kernel` plus
`-options`.
- A `$name` that has no value is left in the string as written.
- Values are layered. A key in a generator or entry overrides the same key set globally, and only for that
entry. From weakest to strongest: `[values]`, extractor results, generator values (and `variants`), then the
`values` of the entry itself.
- Windows-style paths need doubled backslashes in TOML strings: `"\\EFI\\Linux"`. Literal strings
(`'\EFI\Linux'`) work too.
## `options`
```toml
[options]
default-entry = "boot-linux"
menu-timeout = 5
menu-style = "graphical"
autoconfigure = false
bls-strict-mode = false
```
| Key | Type | Default | Meaning |
|-------------------|---------|----------|-------------------------------------------------------------------------|
| `default-entry` | string | none | The entry that is selected first, and booted when the timeout ends. Without it, the first entry in the menu is. |
| `menu-timeout` | integer | see below | Seconds to show the menu before booting the default entry. |
| `menu-style` | string | `simple` | `basic` prints entries and takes a number. `simple` is a full-screen list moved with the arrow keys. `graphical` is a menu drawn on the screen (the mouse is off unless you enable it, see [`menu-styles`](#menu-styles)). |
| `autoconfigure` | boolean | `false` | Add entries for what Sprout finds. See [autoconfiguration](#autoconfiguration). |
| `bls-strict-mode` | boolean | `false` | See [strict mode](#strict-mode). |
When `menu-timeout` is not set anywhere, the menu shows for 10 seconds, or is hidden in strict mode. The
sources are ranked in [The bootloader interface](#the-bootloader-interface).
## `menu-styles`
Settings for a particular menu style. Only the graphical menu has any.
```toml
[menu-styles.graphical]
enable-mouse = true
enable-logo = false
enable-animation = false
```
| Key | Type | Default | Meaning |
|--------------------|---------|---------|------------------------------------------------------------------|
| `enable-mouse` | boolean | `false` | Select entries with the mouse and show the cursor. Without it, or when the firmware has no pointing device, the menu takes the keyboard only. |
| `enable-logo` | boolean | `true` | Show the Sprout logo next to the name. Without it, only the name is shown. |
| `enable-animation` | boolean | `true` | Bounce the logo. Without it, the logo stays still. |
## `drivers`
Drivers are EFI executables that add features, most often a filesystem driver for something the firmware
can't read, such as ext4. Each is started once and expected to return. Afterwards Sprout reconnects every
controller so the new filesystems appear. Drivers have no defined load order.
```toml
[drivers.ext4]
path = "\\sprout\\drivers\\ext4.efi"
provides = "filesystems"
```
| Key | Default | Meaning |
|------------|---------|-------------------------------------------------------------------------|
| `path` | | Path to the driver image. Required. Can use values. |
| `provides` | `any` | What the driver provides: `any` or `filesystems`. |
With `filesystems`, the driver only adds filesystem or disk support. When every driver says so, Sprout
reconnects only the disk and partition controllers instead of all of them. A single `any` driver makes
Sprout reconnect every controller.
The name (`ext4` above) is only for error messages and doesn't have to match the file.
## `extractors`
An extractor works out a value when Sprout boots, so a configuration doesn't need to hardcode where things
are. The name of the extractor is the name of the value it sets.
There is one kind, `filesystem-device-match`, which finds a filesystem and returns its device root. The
result has a trailing slash, so you append a path to it directly: `$boot\\vmlinuz`.
```toml
[extractors.boot]
filesystem-device-match.has-label = "BOOT"
filesystem-device-match.fallback = "PciRoot(0x0)/Pci(0x1,0x1)/Sata(0,0,0)/HD(1,GPT,c2a4f1b6-0000-0000-0000-000000000000)/"
```
| Key | Meaning |
|--------------------------|----------------------------------------------------------------------|
| `has-label` | The filesystem volume label equals this. |
| `has-item` | This file or directory exists on the filesystem. |
| `has-partition-uuid` | The unique GUID of the partition. |
| `has-partition-type-uuid`| The partition type GUID, such as the one for XBOOTLDR. |
| `fallback` | What to use when no filesystem matches. Without it, no match is an error. |
Every criterion you give must match, and you must give at least one. The first matching filesystem wins.
`has-label` and `has-item` can use values.
## `actions`
An action is a named, configured piece of work. Entries and phases refer to actions by name, and an action
can be used by as many of them as you like.
Each action sets exactly one of `chainload`, `print` or `edera`. If you set more than one Sprout warns and
uses the first of them in that order.
### `chainload`
Loads and starts another EFI image. That covers Linux through its EFI stub, Windows, and any other EFI
application. If the image returns, Sprout returns to its own flow.
```toml
[actions.boot-linux]
chainload.path = "\\vmlinuz"
chainload.options = ["root=/dev/sda1", "quiet"]
chainload.linux-initrd = "\\initrd"
```
| Key | Type | Meaning |
|----------------------|-----------------|----------------------------------------------------------------------|
| `path` | string | The image to start. Required. |
| `options` | list of strings | Joined with spaces and given to the image as its command line. |
| `linux-initrd` | string | One initrd, handed to the Linux EFI stub through the initrd media loader. |
| `linux-initrd-chain` | list of strings | Several initrds, concatenated in order into one. Can't be used together with `linux-initrd`. |
| `devicetree` | string | A flattened devicetree to install until the image returns. |
Notes:
- `linux-initrd` is better than an `initrd=` option, because it doesn't depend on the stub resolving a path.
- Entries in `linux-initrd-chain` that are empty after substitution are skipped, which is what makes a
chain with spare slots safe.
- The devicetree is ignored with a warning when Secure Boot is on, as Sprout can't verify it. If it can't be
installed, Sprout warns and boots without it, unless [strict mode](#strict-mode) is on.
- When the image is a Windows boot manager or another non-Linux image, leave the initrd keys out.
### `print`
Writes a line to the console. It's mostly for phases, such as a message while drivers load.
```toml
[actions.hello]
print.text = "Booting $version"
```
### `edera`
Boots the Edera hypervisor and its root operating system, as an extension of the Xen EFI stub. Sprout hands
the Xen config, dom0 kernel and optional initrd to Xen as media loaders, then starts Xen.
```toml
[actions.edera]
edera.xen = "\\EFI\\edera\\xen.efi"
edera.kernel = "\\EFI\\edera\\kernel"
edera.initrd = "\\EFI\\edera\\initrd"
edera.xen-options = ["dom0_mem=512M"]
edera.kernel-options = ["console=hvc0"]
```
| Key | Type | Meaning |
|------------------|-----------------|-------------------------------------------------------------|
| `xen` | string | The Xen EFI image. Required. |
| `kernel` | string | The dom0 kernel. Required. |
| `initrd` | string | The dom0 initrd. |
| `initrd-chain` | list of strings | Several initrds, concatenated in order. Can't be used with `initrd`. |
| `kernel-options` | list of strings | Command line of the dom0 kernel. |
| `xen-options` | list of strings | Command line of Xen. |
When a TPM is present, the Xen options and dom0 command line are measured into PCR 12, the kernel into
PCR 11, and the initrd into PCR 9.
## `entries`
An entry is something the user can pick from the menu. The table name identifies it.
```toml
[entries.boot-linux]
title = "Boot Linux"
actions = ["boot-linux"]
values.kernel = "\\vmlinuz"
sort-key = "10"
```
| Key | Type | Meaning |
|------------|-----------------|------------------------------------------------------------------------|
| `title` | string | The text in the menu. Required, and can use values. |
| `actions` | list of strings | Actions to run, in order, when the entry is chosen. |
| `values` | table | Values that exist only for this entry. |
| `sort-key` | string | Orders the entry in the menu. Compared as versions, not as text, so `10` sorts after `9`. Defaults to the entry's name. |
The menu lists entries in reverse sort order, newest first. That is what you want for kernel versions and
is why `sort-key = "$version"` gives a sensible menu.
`--boot` and the bootloader interface find an entry by its name, by its title, or by its position in the
menu counted from 0. A `*` in the pattern matches anything.
If an entry fails Sprout reports the error and waits ten seconds. Then it exits to the firmware, or resets the
machine when `reboot-on-error` applies (see [boot counting](#boot-counting)).
## `generators`
A generator makes entries from data when Sprout starts. Each has a kind (`matrix`, `list` or `bls`), and
optionally `variants` and `exclude`, which work on the result of any kind. Set only one kind per generator.
The entries a generator makes are named `<generator>-<n>`, with `n` counting from 0, and a BLS generator
uses the name of the entry file instead. Every one of them is built from the `entry` template you give,
which takes the same keys as an [entry](#entries).
### `matrix`
Makes an entry for every combination of the lists you give.
```toml
[generators.kernels]
matrix.entry.title = "Boot $kernel with $console"
matrix.entry.actions = ["boot-kernel"]
matrix.values.kernel = ["\\vmlinuz", "\\vmlinuz-lts"]
matrix.values.console = ["ttyS0", "tty0"]
```
That is four entries. A list of two and a list of three give six.
### `list`
Makes one entry per item. Each item is a table of values. Use it when the combinations aren't a clean grid.
```toml
[generators.systems]
list.entry.title = "Boot $name"
list.entry.actions = ["boot-system"]
list.values = [
{ name = "Alpine", kernel = "\\alpine\\vmlinuz" },
{ name = "Debian", kernel = "\\debian\\vmlinuz" },
]
```
### `bls`
Makes entries from the files of the [Bootloader Specification](#bootloader-specification-support).
```toml
[generators.bls]
bls.path = "\\loader"
bls.entry.title = "$title"
bls.entry.actions = ["boot-bls"]
```
| Key | Type | Default | Meaning |
|--------------|---------|-------------------|-------------------------------------------------------------------|
| `entry` | table | | The template entry. Required. |
| `path` | string | `\loader` | The directory that holds the `entries` directory. Can include a device, like `$boot\\loader`. |
| `uki-path` | string | `\EFI\Linux` on the device of `path` | Where to look for unified kernel images. An empty string turns them off. |
| `xbootldr` | boolean | `false` | Also read the extended boot loader partition of the same disk. |
| `pin-names` | boolean | `true` | Keep the entry file's name as the entry name, instead of prefixing the generator name. |
Two generators reading the same directory produce entries with the same name. Sprout warns about it, since
picking by name becomes ambiguous. `pin-names` is what makes names match the ids that `bootctl` uses.
The values each BLS entry provides are listed under [BLS values](#bls-values).
### `variants`
Variants multiply the entries of a generator. Each key is an axis, and each axis is a list of choices.
Every entry is made once for each combination of choices, taking one from each axis.
```toml
[generators.kernels]
matrix.entry.title = "Boot $kernel ($console)"
matrix.entry.actions = ["boot-kernel"]
matrix.values.kernel = ["\\vmlinuz", "\\vmlinuz-lts"]
variants.console = [
{ name = "serial", values.console-options = "console=ttyS0" },
{ name = "graphics", values.console-options = "console=tty0" },
]
```
This makes "Boot \vmlinuz (serial)", "Boot \vmlinuz (graphics)" and the same two for `\vmlinuz-lts`. Four
entries from a matrix of two kernels and two choices.
A choice has a `name` and optional `values`. The axis name becomes a value holding the choice's name
(`$console` is `serial` here), and the choice's own values are added next to it. Names are appended to the
entry name, so the entries above are called `kernels-0-serial`, `kernels-0-graphics` and so on. Axes apply in
alphabetical order, choices in the order written. An axis with no choices, or two choices that share a name,
is an error.
### `exclude`
Drops generated entries. It's a list of rules, and a rule is a table of value names to patterns, where `*`
matches anything. An entry is dropped if every value in at least one rule matches. It runs after variants,
and compares values before they are substituted.
```toml
[generators.bls]
# ...
exclude = [
{ version = "0-rescue-*" },
{ console = "graphics", mode = "debug" },
]
```
The first rule drops BLS rescue entries. The second drops only the one combination.
## `phases`
Phases run actions at fixed points in the boot. Each phase is a list, and each item names actions and can
add values for them.
| Phase | Runs |
|-----------|------------------------------------------------------------|
| `early` | Before drivers load. |
| `startup` | After drivers, autoconfiguration and extractors, before the menu is built. |
| `late` | After an entry is chosen, before its actions run. |
```toml
[[phases.early]]
actions = ["splash"]
[[phases.startup]]
actions = ["announce"]
values.message = "drivers are loaded"
```
Items run in order, and an action that fails stops the boot with an error. Note the double brackets: each
item is one element of a list.
## Autoconfiguration
With `autoconfigure = true`, or `--autoconfigure`, Sprout looks at every filesystem it can read and adds
configuration for what it finds. With the option, the result is added to your file. With the flag, your file
is not read at all, which makes it a good way to get a machine booting before you've written one.
For each filesystem:
- **BLS**: if it has `\loader\loader.conf`, a `\loader\entries` directory with `.conf` files, or `.efi` files
in `\EFI\Linux`, Sprout adds a `bls` generator and the action that boots its entries. These are named
`auto-bls-<hash>`.
- **Linux**: if there was no BLS, kernels in `\boot` and `\` are found by name (`vmlinuz` or `Image`,
optionally followed by `-` and a version), paired with an `initramfs`, `initrd` or `initrd.img` of the same
suffix. The entries are named `auto-linux-<hash>` and titled "Boot Linux" plus the path. Command line
options come from the value `linux-options`, which you can set in `[values]` and which otherwise is the
placeholder `placeholder`.
- **Windows**: if `\EFI\Microsoft\Boot\bootmgfw.efi` exists, an entry "Boot Windows" named
`auto-windows-<hash>` chainloads it.
`<hash>` is a short hash of the filesystem's device path, so two disks never collide. [Strict
mode](#strict-mode) narrows which filesystems the BLS part reads.
## Bootloader Specification support
Sprout implements the [Boot Loader Specification](https://uapi-group.org/specifications/specs/boot_loader_specification/)
to read the entries that distributions install, and sorts them as the specification says.
It reads Type #1 entries from `\loader\entries` and Type #2 unified kernel images (UKIs) from `\EFI\Linux`. A
UKI with several profiles is one entry per profile. Entries for another architecture than the machine's are
hidden. In a Type #1 entry Sprout uses the keys `title`, `version`, `machine-id`, `sort-key`, `linux`, `efi`,
`uki`, `initrd`, `options`, `devicetree`, `architecture` and `profile`.
The simplest way to get this is `autoconfigure = true`. To write it out by hand, you need a `bls` generator
and an action that boots what it makes:
```toml
[generators.bls]
bls.entry.title = "$title"
bls.entry.actions = ["boot-bls"]
bls.xbootldr = true
[actions.boot-bls]
chainload.path = "$entry-root\\$chainload"
chainload.options = ["$options"]
chainload.devicetree = "$entry-root\\$devicetree"
chainload.linux-initrd-chain = [
"$entry-root\\$initrd-0",
"$entry-root\\$initrd-1",
"$entry-root\\$initrd-2",
"$entry-root\\$initrd-3",
]
```
An entry can have up to 32 initrds, and Sprout warns when it has one that the chain doesn't list, because
the kernel likely can't then find its root. Autoconfiguration lists all 32. Unused slots are empty and
skipped, so listing more than you need costs nothing.
### BLS values
| Value | Holds |
|----------------------|----------------------------------------------------------------------------|
| `$title` | The title, with the version added unless it already contains it. |
| `$title-base` | The title as written in the entry. |
| `$version` | The `version` field. |
| `$machine-id` | The `machine-id` field. |
| `$chainload` | The path of the `linux`, `efi` or `uki` file. |
| `$options` | The `options` field, with the profile prepended for a UKI profile. |
| `$cmdline` | The command line embedded in a UKI. |
| `$uname` | The kernel version embedded in a UKI. |
| `$devicetree` | The `devicetree` path. |
| `$initrd-0` to `$initrd-31` | The initrds, in order. A slot with none is empty. |
| `$initrd` | The last initrd. |
| `$entry-root` | The device the entry's files are on. Empty for Sprout's own partition. |
`$entry-root` matters once `xbootldr` is on, since an entry on the XBOOTLDR partition has its files there,
not next to Sprout. Always write BLS paths as `$entry-root\\$chainload`.
### Boot counting
A file named like `fedora+3.conf` or `fedora+3.efi` has three tries. Each boot renames it, say to
`fedora+2-1.conf`, and tells the system the new path in `LoaderBootCountPath` so `systemd-bless-boot` can
remove the counter once the boot works.
Entries with no tries left sort last and are never the default, but can still be booted by hand. If an entry
fails to start after it used a try, and it had tries left, the machine resets so the next boot can use the
next try or pick another entry. This is the `reboot-on-error` setting of `loader.conf`. Without boot counting
a failure to start goes back to the firmware.
### `loader.conf`
Sprout reads `\loader\loader.conf` from the partition it was loaded from, as systemd-boot does. It is
measured into the TPM, since it changes how Sprout boots.
| Key | Value |
|-------------------|--------------------------------------------------------------------|
| `default` | A pattern for the id of the default entry, or `@saved`. |
| `preferred` | Like `default`, but entries with no boot counter tries are skipped. |
| `timeout` | Seconds, `menu-hidden`, `menu-disabled` or `menu-force`. |
| `reboot-on-error` | `auto` (the default), `yes` or `no`. |
The id of an entry is its file name without the boot counter, like `fedora.conf` or `fedora.efi`. Patterns
ignore case and can use `*`, `?` and `[a-z]`. With `@saved` the entry booted last is the default.
`reboot-on-error = yes` resets after every failed start, which can loop forever. `auto` only resets when a
try was used up and some were left. Other keys are ignored with a warning.
A hidden menu, from a timeout of zero or `menu-hidden`, still opens when a key is pressed. `menu-disabled`
does not.
### The bootloader interface
Sprout uses the same firmware variables as systemd-boot, so `bootctl`, `systemctl reboot --boot-loader-entry`
and `systemd-bless-boot` work with it. It publishes `LoaderEntries`, `LoaderEntrySelected`,
`LoaderBootCountPath`, `LoaderFeatures` and `LoaderInfo`, and reads `LoaderEntryDefault`,
`LoaderEntryPreferred`, `LoaderEntryOneShot`, `LoaderEntryLastBooted`, `LoaderConfigTimeout` and
`LoaderConfigTimeoutOneShot`.
What `bootctl` sets outranks `sprout.toml`, which outranks `loader.conf`. The default entry is the first of
these that matches an entry:
1. `LoaderEntryPreferred`, then `preferred` in `loader.conf`
2. `LoaderEntryDefault`
3. `default-entry` in `sprout.toml`
4. `default` in `loader.conf`
The menu timeout is the first of these that is set:
1. the one-shot timeout, `LoaderConfigTimeoutOneShot`
2. `--menu-timeout`
3. `LoaderConfigTimeout`
4. `menu-timeout` in `sprout.toml`
5. `timeout` in `loader.conf`
A one-shot entry (`LoaderEntryOneShot`) is booted at once and without the menu, unless strict mode is on.
## Strict mode
By default Sprout departs from systemd-boot in a few places, where it is friendlier or safer for a
bootloader that reads the same files. Each departure is logged when it applies. `--bls-strict-mode`, or
`bls-strict-mode = true` under `[options]`, turns all of them off.
| Behavior | By default | In strict mode |
|---------------------------------------------------|---------------------------------------------------------|---------------------------------------------------------|
| One-shot entry (`LoaderEntryOneShot`) | Booted at once, without the menu. | Only the default for this boot. The menu and its timeout still apply. |
| Menu timeout that nothing sets | The menu is shown for 10 seconds. | The menu is hidden. |
| Default entry with no boot counter tries | Skipped for another entry. | Used, as `default` ignores the tries. |
| Entry with more than one of `linux`, `efi`, `uki` | Boots the first of them. | Hidden. |
| Entry whose file does not exist | Shown, and it fails when booted. | Hidden. |
| UKI without a name | Named after its file. | Hidden. |
| Name and version of a UKI | Name is `PRETTY_NAME` or `ID`. Version is `IMAGE_VERSION`, `VERSION_ID`, `BUILD_ID`, then `.uname`. | What systemd-boot uses. Name is `PRETTY_NAME`, `IMAGE_ID`, `NAME` or `ID`. Version is `IMAGE_VERSION`, `VERSION`, `VERSION_ID` or `BUILD_ID`. |
| Boot counter of a plain `efi` entry | Counted. | Not counted, but its tries still make it bad. |
| Extended boot loader partition | Read when `xbootldr` is set on the generator. | Always read. |
| Autoconfiguration | Reads BLS entries from every disk, one generator each. | Reads BLS entries from Sprout's partition and the XBOOTLDR of its disk, as one generator. Windows and Linux entries are found as before. |
| Boot entry that returns | Sprout exits to the firmware. | The menu is shown again. |
| Devicetree that can't be installed | Warns and boots without it. | The entry fails. |
In strict mode the actions of a hand-written generator have to use `$entry-root` for the files of an entry,
as entries can come from the extended boot loader partition.
+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
+47 -10
View File
@@ -9,13 +9,18 @@ if [ "${SKIP_BUILD}" != "1" ]; then
./hack/dev/build.sh "${TARGET_ARCH}" "${RUST_PROFILE}"
fi
clear
[ -t 1 ] && clear
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
@@ -30,22 +35,33 @@ if [ "${QEMU_GDB_WAIT}" = "1" ]; then
set -- "${@}" "-S"
fi
set -- "${@}" -smp 2 -m 4096
set -- "${@}" -nodefaults -smp 2 -m 4096
if [ "${NO_GRAPHICAL_BOOT}" = "1" ]; then
if [ "${NO_GRAPHICAL}" = "1" ]; then
set -- "${@}" -nographic
else
if [ "${GRAPHICAL_ONLY}" != "1" ]; then
if [ "${GRAPHICAL_ONLY}" != "1" ] && [ "${QEMU_HEADLESS}" != "1" ]; then
if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then
set -- "${@}" -serial stdio
else
set -- "${@}" \
-device virtio-serial-pci,id=vs0 \
-chardev stdio,id=stdio0 \
-device virtconsole,chardev=stdio0,id=console0
-device 'virtio-serial-pci,id=vs0' \
-chardev 'stdio,id=stdio0,signal=off' \
-device 'virtconsole,chardev=stdio0,id=console0,name=alpine'
fi
fi
# Headless keeps the virtual GPU, so screenshots still work, but shows no window. The firmware
# console and the Linux console go to files, as there is no terminal to attach to.
if [ "${QEMU_HEADLESS}" = "1" ]; then
set -- "${@}" \
-display none \
-serial "file:${QEMU_SERIAL_FILE:-/dev/null}" \
-device 'virtio-serial-pci,id=vs0' \
-chardev "file,id=file0,path=${QEMU_CONSOLE_FILE:-/dev/null}" \
-device 'virtconsole,chardev=file0,id=console0,name=alpine'
fi
if [ "${QEMU_LEGACY_VGA}" = "1" ]; then
set -- "${@}" -vga std
else
@@ -62,19 +78,40 @@ if [ "${NO_INPUT}" != "1" ]; then
-device usb-mouse
fi
if [ "${NO_NETWORK}" != "1" ]; then
set -- "${@}" \
-netdev 'user,id=network0' \
-device 'virtio-net-pci,netdev=network0'
fi
# The firmware in the dev image hangs before it starts Sprout when accelerated on Apple Silicon,
# so use the firmware that comes with Homebrew's QEMU there. QEMU_FIRMWARE overrides the choice.
if [ -z "${QEMU_FIRMWARE}" ] && [ "${TARGET_ARCH}" = "aarch64" ] && [ "${QEMU_ACCEL}" = "hvf" ] &&
command -v brew >/dev/null 2>&1; then
BREW_FIRMWARE="$(brew --prefix qemu 2>/dev/null)/share/qemu/edk2-aarch64-code.fd"
[ -f "${BREW_FIRMWARE}" ] && QEMU_FIRMWARE="${BREW_FIRMWARE}"
fi
rm -f "${FINAL_DIR}/ovmf-boot.fd"
cp "${FINAL_DIR}/ovmf.fd" "${FINAL_DIR}/ovmf-boot.fd"
cp "${QEMU_FIRMWARE:-${FINAL_DIR}/ovmf.fd}" "${FINAL_DIR}/ovmf-boot.fd"
if [ "${TARGET_ARCH}" = "aarch64" ]; then
dd if=/dev/zero of="${FINAL_DIR}/ovmf-boot.fd" bs=1 count=1 seek=67108863 >/dev/null 2>&1
fi
# shellcheck disable=SC2086
set -- "${@}" \
-drive "if=pflash,file=${FINAL_DIR}/ovmf-boot.fd,format=raw,readonly=on" \
-device nvme,drive=disk1,serial=cafebabe
-device 'nvme,drive=disk1,serial=cafebabe'
set -- "${@}" \
-drive "if=none,file=${FINAL_DIR}/sprout.img,format=raw,id=disk1,readonly=on"
set -- "${@}" -name "sprout ${TARGET_ARCH}"
if [ -n "${QEMU_QMP}" ]; then
set -- "${@}" -qmp "unix:${QEMU_QMP},server=on,wait=off"
fi
# shellcheck disable=SC2086
set -- "${@}" ${QEMU_EXTRA_ARGS}
exec "${@}"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM debian:trixie@sha256:01a723bf5bfb21b9dda0c9a33e0538106e4d02cce8f557e118dd61259553d598 AS build
FROM --platform=$BUILDPLATFORM debian:trixie@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c AS build
ARG BUILDPLATFORM
ARG EFI_NAME
RUN export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -y \
+8 -19
View File
@@ -48,7 +48,7 @@ copy_from_image_polyfill() {
SOURCE="${2}"
TARGET="${3}"
docker build -t "${IMAGE}-copy-polyfill:${DOCKER_TAG}" --build-arg "TARGET_IMAGE=${IMAGE}:${DOCKER_TAG}" \
docker build --platform="${DOCKER_TARGET}" -t "${IMAGE}-copy-polyfill:${DOCKER_TAG}" --build-arg "TARGET_IMAGE=${IMAGE}:${DOCKER_TAG}" \
-f hack/dev/utils/Dockerfile.copy-polyfill hack
# note: the -w '//' is a workaround for Git Bash where / is magically rewritten.
docker run --rm -i -w '//' "${IMAGE}-copy-polyfill:${DOCKER_TAG}" cat "image/${SOURCE}" >"${TARGET}"
@@ -72,6 +72,7 @@ if [ "${SKIP_KERNEL_BUILD}" != "1" ]; then
fi
copy_from_image "${DOCKER_PREFIX}/sprout-kernel-${TARGET_ARCH}" "kernel.efi" "${FINAL_DIR}/kernel.efi"
copy_from_image "${DOCKER_PREFIX}/sprout-kernel-${TARGET_ARCH}" "kernel.modules.tgz" "${FINAL_DIR}/kernel.modules.tgz"
fi
if [ "${SKIP_VM_BUILD}" != "1" ]; then
@@ -80,8 +81,12 @@ if [ "${SKIP_VM_BUILD}" != "1" ]; then
-f hack/dev/vm/Dockerfile.ovmf "${FINAL_DIR}"
copy_from_image "${DOCKER_PREFIX}/sprout-ovmf-${TARGET_ARCH}" "ovmf.fd" "${FINAL_DIR}/ovmf.fd"
copy_from_image "${DOCKER_PREFIX}/sprout-ovmf-${TARGET_ARCH}" "shell.efi" "${FINAL_DIR}/shell.efi"
rm -rf "${FINAL_DIR}/initramfs.build"
mkdir -p "${FINAL_DIR}/initramfs.build"
cp -r "hack/dev/vm/files" "${FINAL_DIR}/initramfs.build/files"
cp "${FINAL_DIR}/kernel.modules.tgz" "${FINAL_DIR}/initramfs.build/kernel.modules.tgz"
docker build --platform="${DOCKER_TARGET}" -t "${DOCKER_PREFIX}/sprout-initramfs-${TARGET_ARCH}:${DOCKER_TAG}" \
-f hack/dev/vm/Dockerfile.initramfs "${FINAL_DIR}"
-f hack/dev/vm/Dockerfile.initramfs "${FINAL_DIR}/initramfs.build"
copy_from_image "${DOCKER_PREFIX}/sprout-initramfs-${TARGET_ARCH}" "initramfs" "${FINAL_DIR}/initramfs"
if [ -n "${SPROUT_XEN_EFI_OVERRIDE}" ]; then
@@ -100,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"
@@ -108,23 +114,6 @@ if [ "${SKIP_SPROUT_BUILD}" != "1" ]; then
cp "hack/dev/configs/${SPROUT_CONFIG_NAME}.sprout.toml" "${FINAL_DIR}/sprout.toml"
cp "hack/dev/configs/xen.cfg" "${FINAL_DIR}/xen.cfg"
cp "hack/dev/configs/bls.conf" "${FINAL_DIR}/bls.conf"
mkdir -p "${FINAL_DIR}/efi/EFI/BOOT"
cp "${FINAL_DIR}/sprout.efi" "${FINAL_DIR}/efi/EFI/BOOT/${EFI_NAME}.EFI"
if [ -f "${FINAL_DIR}/kernel.efi" ]; then
cp "${FINAL_DIR}/kernel.efi" "${FINAL_DIR}/efi/EFI/BOOT/KERNEL.EFI"
fi
if [ -f "${FINAL_DIR}/shell.efi" ]; then
cp "${FINAL_DIR}/shell.efi" "${FINAL_DIR}/efi/EFI/BOOT/SHELL.EFI"
fi
if [ -f "${FINAL_DIR}/xen.efi" ]; then
cp "${FINAL_DIR}/xen.efi" "${FINAL_DIR}/efi/EFI/BOOT/XEN.EFI"
fi
if [ -f "${FINAL_DIR}/xen.cfg" ]; then
cp "${FINAL_DIR}/xen.cfg" "${FINAL_DIR}/efi/EFI/BOOT/XEN.CFG"
fi
cp "${FINAL_DIR}/sprout.toml" "${FINAL_DIR}/efi/SPROUT.TOML"
cp "${FINAL_DIR}/initramfs" "${FINAL_DIR}/efi/INITRAMFS"
fi
if [ "${SKIP_BOOT_BUILD}" != "1" ]; then
+1 -1
View File
@@ -8,7 +8,7 @@ has-item = "\\vmlinuz"
[actions.chainload-kernel]
chainload.path = "$boot\\vmlinuz"
chainload.options = ["console=hvc0"]
chainload.options = ["console=hvc0", "overlaytmpfs=yes"]
chainload.linux-initrd = "$boot\\initramfs"
[entries.kernel]
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more