Compare commits

..
Author SHA1 Message Date
alex a4c31cc402 update sprout logo 2025-10-24 16:53:13 -07:00
alex 226e284edc add favicon 2025-10-19 22:23:27 -07:00
alex 13b8606d9f fix typo 2025-10-19 22:15:56 -07:00
alex bece37cf3a initial sprout website 2025-10-19 22:14:35 -07:00
168 changed files with 76 additions and 17794 deletions

No files matched your search

@@ -1,83 +0,0 @@
---
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.
-3
View File
@@ -1,3 +0,0 @@
/target
/.idea
/.vscode
-1
View File
@@ -1 +0,0 @@
* @azenla
-40
View File
@@ -1,40 +0,0 @@
name: Report a bug
description: File a bug report.
title: "bug: "
labels: ["bug", "triage"]
type: bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: version
attributes:
label: Version / Commit
description: What version of Sprout are you running?
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Tell us what you expected to happen.
placeholder: Tell us what you see!
value: "A bug happened!"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Log output
description: Please provide any relevant log output.
render: log
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this report, you agree to follow our [Code of Conduct](https://github.com/edera-dev/sprout/blob/main/CODE_OF_CONDUCT.md).
options:
- label: I agree to follow the Sprout Code of Conduct.
required: true
-6
View File
@@ -1,6 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Ask a question
url: https://github.com/edera-dev/sprout/discussions
about: Please ask and answer questions here.
# Note that GitHub will automatically display our security policy in the new issue form.
@@ -1,27 +0,0 @@
name: Request a feature
description: Request a feature.
title: "want: "
labels: ["enhancement", "triage"]
type: feature
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out a feature request!
- type: textarea
id: description
attributes:
label: Description
description:
placeholder: Tell us what you want to see!
value: "Your hopes and dreams here!"
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this report, you agree to follow our [Code of Conduct](https://github.com/edera-dev/sprout/blob/main/CODE_OF_CONDUCT.md).
options:
- label: I agree to follow the Sprout Code of Conduct.
required: true
-5
View File
@@ -1,5 +0,0 @@
name: "codeql-config"
extractor-options:
rust:
cargo_target: x86_64-unknown-uefi
-41
View File
@@ -1,41 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
actions-updates:
dependency-type: production
applies-to: version-updates
actions-dev-updates:
dependency-type: development
applies-to: version-updates
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
cargo-updates:
dependency-type: production
applies-to: version-updates
cargo-dev-updates:
dependency-type: development
applies-to: version-updates
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
docker-updates:
dependency-type: production
applies-to: version-updates
docker-dev-updates:
dependency-type: development
applies-to: version-updates
-50
View File
@@ -1,50 +0,0 @@
name: zizmor
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read # Needed to checkout the repository.
concurrency:
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:
name: zizmor
runs-on: ubuntu-latest
permissions:
security-events: write # Needed to upload code scanning results.
contents: read # Needed to checkout the repository.
actions: read # Needed to analyze action metadata.
steps:
- name: harden runner
uses: step-security/harden-runner@351661ca32ac09a36dc5ee2d536e3128f2a3c8ed # v2.22.0
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: setup uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
- name: zizmor
run: uvx zizmor --pedantic --format sarif . > results.sarif
env:
GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: upload
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
sarif_file: results.sarif
category: zizmor
-99
View File
@@ -1,99 +0,0 @@
name: check
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read # Needed to checkout the repository.
concurrency:
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:
name: rustfmt
runs-on: ubuntu-latest
steps:
- name: harden runner
uses: step-security/harden-runner@351661ca32ac09a36dc5ee2d536e3128f2a3c8ed # v2.22.0
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: 'install rust toolchain with rustfmt'
run: |
cargo version
rustup component add rustfmt
- name: 'cargo fmt'
run: cargo fmt --all -- --check
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
arch:
- x86_64
- aarch64
env:
TARGET_ARCH: "${{ matrix.arch }}"
name: 'build ${{ matrix.arch }}'
steps:
- name: harden runner
uses: step-security/harden-runner@351661ca32ac09a36dc5ee2d536e3128f2a3c8ed # v2.22.0
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: 'install rust toolchain'
run: |
cargo version
- name: cargo build
run: cargo build --target "${TARGET_ARCH}-unknown-uefi"
clippy:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
arch:
- x86_64
- aarch64
env:
TARGET_ARCH: "${{ matrix.arch }}"
name: 'clippy ${{ matrix.arch }}'
steps:
- name: harden runner
uses: step-security/harden-runner@351661ca32ac09a36dc5ee2d536e3128f2a3c8ed # v2.22.0
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: 'install rust toolchain with clippy'
run: |
cargo version
rustup component add clippy
- name: cargo clippy
run: cargo clippy --target "${TARGET_ARCH}-unknown-uefi"
-60
View File
@@ -1,60 +0,0 @@
name: codeql
on:
pull_request:
branches:
- main
push:
branches:
- main
schedule:
- cron: '33 16 * * 0'
permissions:
contents: read # Needed to checkout the repository.
concurrency:
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:
name: analyze (${{ matrix.language }})
runs-on: 'ubuntu-latest'
permissions:
security-events: write # Needed to upload results.
packages: read # Needed to fetch internal or private CodeQL packs.
actions: read # Needed to read workflows.
contents: read # Needed to checkout the repository.
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: rust
build-mode: none
steps:
- name: harden runner
uses: step-security/harden-runner@351661ca32ac09a36dc5ee2d536e3128f2a3c8ed # v2.22.0
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: initialize codeql
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:${{matrix.language}}"
-72
View File
@@ -1,72 +0,0 @@
name: publish
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read # Needed to checkout the repository.
concurrency:
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: 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@351661ca32ac09a36dc5ee2d536e3128f2a3c8ed # v2.22.0
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: 'install rust toolchain'
run: |
cargo version
- name: 'assemble artifacts'
run: ./hack/assemble.sh
- name: 'upload artifacts'
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@351661ca32ac09a36dc5ee2d536e3128f2a3c8ed # v2.22.0
with:
egress-policy: audit
- name: 'attest artifacts'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: artifacts.zip
subject-digest: "sha256:${{ needs.artifacts.outputs.artifact-digest }}"
-75
View File
@@ -1,75 +0,0 @@
name: release
on:
workflow_dispatch:
inputs:
release-tag:
description: 'Release Tag'
required: true
type: string
permissions:
contents: read # Needed to checkout the repository.
concurrency:
group: "${{ github.workflow }}-${{ github.event.inputs.release-tag }}"
# Let a running release finish, so its assets are never left partially uploaded.
cancel-in-progress: false
jobs:
release:
name: release
permissions:
contents: write # Needed to upload release assets.
id-token: write # Needed for attestation.
attestations: write # Needed for attestations.
runs-on: ubuntu-latest
steps:
- name: harden runner
uses: step-security/harden-runner@351661ca32ac09a36dc5ee2d536e3128f2a3c8ed # v2.22.0
with:
egress-policy: audit
- name: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Build the tagged commit, not whichever ref the workflow was dispatched on.
ref: "refs/tags/${{ github.event.inputs.release-tag }}"
persist-credentials: false
- name: 'install rust toolchain'
run: |
cargo version
- name: 'assemble release artifacts'
run: ./hack/assemble.sh
- name: 'attest release artifacts'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: target/assemble/*
- name: 'generate cultivator token'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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'
run: ./hack/ci/upload-release-assets.sh
env:
GITHUB_TOKEN: "${{ steps.generate-token.outputs.token }}"
RELEASE_TAG: "${{ github.event.inputs.release-tag }}"
if: ${{ github.event.inputs.release-tag != '' }}
- name: 'mark release as published'
run: gh release edit "${RELEASE_TAG}" --draft=false --verify-tag
env:
GITHUB_TOKEN: "${{ steps.generate-token.outputs.token }}"
RELEASE_TAG: "${{ github.event.inputs.release-tag }}"
if: ${{ github.event.inputs.release-tag != '' }}
-33
View File
@@ -1,36 +1,3 @@
/target
# Infrastructure
.env
.terraform
terraform.tfstate
terraform.tfstate.backup
aws-packer-config.json
override.install.sh
# Ignore the google cloud auth creds created in Actions workflows
gha-creds-*.json
crane
tok
# IDE
/.idea
/.vscode
# Benchmarks
/results
# cli markdown output
cli.md
# precommit hooks
.editorconfig
.markdownlint-cli2.jsonc
.pre-commit-config.yaml
.prettierrc
.shellcheckrc
# example outputs
/image-cache
/out
View File
Whitespace-only changes.
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
-58
View File
@@ -1,58 +0,0 @@
# 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
View File
@@ -0,0 +1 @@
sprout.edera.dev
-132
View File
@@ -1,132 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
conduct@edera.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
-32
View File
@@ -1,32 +0,0 @@
# Contributing to Sprout
Welcome! We're happy you're reading this; Edera is excited for all kinds of contributions! Please read the following to ensure you're aware of our flow and policies.
## Before Contributing
1. Please read our [Code of Conduct](CODE_OF_CONDUCT.md), which applies to all interactions in/with all Edera projects and venues.
2. Before opening an issue or PR, please try a few searches to see if there is overlap with existing conversations or WIP contributions.
3. For security or otherwise sensitive topics, please read our [Security Policy].
4. Ask questions! If you want to ask something, chances are someone else wants to ask it as well.
## Contributing Code
To get started with technical contributions, please read our [Development Guide]. If you're looking for something easy to tackle, [look for issues labeled `good first issue`][good-first-issues].
## Reporting bugs and other issues
We encourage opening an issue on GitHub to report bugs.
## Pull Requests
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, 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.
[Code of Conduct]: ./CODE_OF_CONDUCT.md
[Development Guide]: ./DEVELOPMENT.md
[Security Policy]: ./SECURITY.md
[good-first-issues]: https://github.com/edera-dev/sprout/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22
Generated
-472
View File
@@ -1,472 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"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.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "edera-sprout-bls"
version = "0.0.30"
dependencies = [
"anyhow",
]
[[package]]
name = "edera-sprout-boot"
version = "0.0.30"
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",
"uefi-raw",
]
[[package]]
name = "edera-sprout-build"
version = "0.0.30"
dependencies = [
"png",
]
[[package]]
name = "edera-sprout-config"
version = "0.0.30"
dependencies = [
"serde",
]
[[package]]
name = "edera-sprout-eficore"
version = "0.0.30"
dependencies = [
"anyhow",
"bitflags",
"log",
"spin",
"uefi",
"uefi-raw",
]
[[package]]
name = "edera-sprout-parsing"
version = "0.0.30"
dependencies = [
"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]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
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.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "spin"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10"
dependencies = [
"lock_api",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "ucs2"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba"
dependencies = [
"bit_field",
]
[[package]]
name = "uefi"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368cef7d94514897e6dad4350945c7910a9f5bf9021191d1c58ee3ec09962697"
dependencies = [
"bitflags",
"cfg-if",
"log",
"ptr_meta",
"ucs2",
"uefi-macros",
"uefi-raw",
"uguid",
]
[[package]]
name = "uefi-macros"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "643efa8b64be89780bc498b290fab04337c7c4d171cc82cce2e133a74616dcc4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "uefi-raw"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3636327a6d88a38c0f2567fb695ec7ba27b3483d1877730486605aa0866da201"
dependencies = [
"bitflags",
"uguid",
]
[[package]]
name = "uguid"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8352f8c05e47892e7eaf13b34abd76a7f4aeaf817b716e88789381927f199c"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
-82
View File
@@ -1,82 +0,0 @@
[workspace]
members = [
"crates/boot",
"crates/build",
"crates/config",
"crates/eficore",
"crates/bls",
"crates/parsing",
]
resolver = "3"
[workspace.package]
license = "Apache-2.0"
version = "0.0.30"
homepage = "https://sprout.edera.dev"
repository = "https://github.com/edera-dev/sprout"
edition = "2024"
[workspace.dependencies]
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.104"
default-features = false
[workspace.dependencies.hex]
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.229"
default-features = false
features = ["alloc", "derive"]
[workspace.dependencies.serde_ignored]
version = "0.1.14"
default-features = false
[workspace.dependencies.sha2]
version = "0.11.0"
default-features = false
[workspace.dependencies.toml]
version = "1.1.6"
default-features = false
features = ["serde", "parse"]
[workspace.dependencies.uefi]
version = "0.41.0"
default-features = false
features = ["alloc", "global_allocator", "panic_handler"]
# Common build profiles
# NOTE: We have to compile everything for opt-level = 2 due to optimization passes
# which don't handle the UEFI target properly.
[profile.dev]
opt-level = 2
[profile.release]
lto = "thin"
strip = "symbols"
[profile.release-debuginfo]
inherits = "release"
strip = "none"
debug = 1
[profile.dev-fast]
inherits = "dev"
strip = "debuginfo"
debug = 0
opt-level = 2
-80
View File
@@ -1,80 +0,0 @@
# Sprout Development Guide
This guide is a work in progress.
## Development Setup
You can use any Rust development environment to develop Sprout.
Rustup is recommended as the Rust toolchain manager to manage Rust versions and targets.
We currently only support `x86_64-unknown-uefi` and `aarch64-unknown-uefi` targets.
To test your changes in QEMU, please run `./hack/dev/boot.sh`, you can specify `x86_64` or `aarch64`
as an argument to boot.sh to boot the specified architecture.
## Crate Structure
Sprout is split into multiple crates:
- `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.
## Hack Scripts
You can use the `./hack` scripts to run common development tasks:
### ./hack/build.sh
Builds the Sprout binary for the target that would support your current machine.
### ./hack/assemble.sh
Builds both x86_64 and aarch64 binaries into `target/assemble`.
### ./hack/clean.sh
Cleans the target directory and any docker images that were built.
### ./hack/format.sh
Formats the code using `rustfmt` and shell scripts with `shfmt`.
### ./hack/autofix.sh
Applies Clippy and `rustfmt` fixes to the code, and formats shell scripts with `shfmt`.
## Dev Scripts
### ./hack/dev/build.sh
Build Sprout as OCI images using Docker, including a kernel, initramfs, xen, and other supporting files.
### ./hack/dev/boot.sh
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).
-24
View File
@@ -1,24 +0,0 @@
ARG RUST_PROFILE=release
ARG RUST_TARGET_SUBDIR=release
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
COPY \
--exclude=rust-toolchain.toml \
--exclude=hack \
--chown=build:build \
. /build
WORKDIR /build
ARG TARGETPLATFORM
ARG RUST_TARGET_SUBDIR
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ] || [ "${TARGETPLATFORM}" = "linux/x86_64" ]; then \
rustup target add x86_64-unknown-uefi; cargo build --bin sprout --profile "${RUST_PROFILE}" --target x86_64-unknown-uefi && \
cp "target/x86_64-unknown-uefi/${RUST_TARGET_SUBDIR}/sprout.efi" /sprout.efi; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ] || [ "${TARGETPLATFORM}" = "linux/aarch64" ]; then \
rustup target add aarch64-unknown-uefi; cargo build --bin sprout --profile "${RUST_PROFILE}" --target aarch64-unknown-uefi && \
cp "target/aarch64-unknown-uefi/${RUST_TARGET_SUBDIR}/sprout.efi" /sprout.efi; fi
FROM scratch AS final
COPY --from=build /sprout.efi /sprout.efi
+2 -159
View File
@@ -1,160 +1,3 @@
<div align="center">
# Sprout Website
![Sprout Logo](assets/logo-small.png)
# Sprout
</div>
Sprout is a programmable UEFI bootloader written in Rust.
It is in use at Edera today in development environments and is intended to ship to production soon.
The name "Sprout" is derived from our company name "Edera" which means "ivy."
Given that Sprout is the first thing intended to start on an Edera system, the name was apt.
It supports `x86_64` and `ARM64` EFI-capable systems. It is designed to require UEFI and can be chainloaded from an
existing UEFI bootloader or booted by the hardware directly.
Sprout is licensed under Apache 2.0 and is open to modifications and contributions.
**NOTE**: Sprout is still in beta. Some features may not work as expected.
Please [report any bugs you find](https://github.com/edera-dev/sprout/issues/new/choose).
## Background
At [Edera] we make compute isolation technology for a wide variety of environments, often ones we do not fully control.
Our technology uses a hypervisor to boot the host system to provide a new isolation mechanism that works
with or without hardware virtualization support. To do this, we need to inject our hypervisor at boot time.
Unfortunately, GRUB, the most common bootloader on Linux systems today, uses a shell-script like
configuration system. Both the code that runs to generate a GRUB config and the GRUB config
itself is fully turing complete. This makes modifying boot configuration difficult and error-prone.
Sprout was designed to take in a machine-readable, writable, and modifiable configuration that treats boot information
like data plus configuration, and can be chained from both UEFI firmware and GRUB alike.
Sprout aims to be flexible, secure, and modern. Written in Rust, it handles data safely and uses unsafe code as little
as possible. It also critically must be easy to install into all common distributions, relying on simple principles to
simplify installation and usage.
## Documentation
### Setup Guides
Some guides support Secure Boot and some do not.
We recommend running Sprout without Secure Boot for development, and with Secure Boot for production.
| Operating System | Secure Boot Enabled | Link |
|------------------|---------------------|-------------------------------------------------------|
| Fedora | ✅ | [Setup Guide](./docs/setup/signed/fedora.md) |
| Debian | ✅ | [Setup Guide](./docs/setup/signed/debian.md) |
| Ubuntu | ✅ | [Setup Guide](./docs/setup/signed/ubuntu.md) |
| openSUSE | ✅ | [Setup Guide](./docs/setup/signed/opensuse.md) |
| Fedora | ❌ | [Setup Guide](./docs/setup/unsigned/fedora.md) |
| Alpine Edge | ❌ | [Setup Guide](./docs/setup/unsigned/alpine-edge.md) |
| Generic Linux | ❌ | [Setup Guide](./docs/setup/unsigned/generic-linux.md) |
| Windows | ❌ | [Setup Guide](./docs/setup/unsigned/windows.md) |
### Project Documentation
- [Configuration Reference]
- [Development Guide]
- [Contributing Guide]
- [Sprout License]
- [Code of Conduct]
- [Security Policy]
## Features
### Current
- [x] Loadable driver 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] 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)
- [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)
- [ ] 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/8)
## Concepts
- drivers: loadable EFI modules that can add functionality to the EFI system.
- autoconfiguration: code that can automatically generate sprout.toml based on the EFI environment.
- actions: executable code with a configuration that can be run by various other sprout concepts.
- generators: code that can generate boot entries based on inputs or runtime code.
- extractors: code that can extract values from the EFI environment.
- values: key-value pairs that can be specified in the configuration or provided by extractors or generators.
- entries: boot entries that will be displayed to the user.
- phases: stages of the bootloader that can be hooked to run actions at specific points.
## Usage
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 reads its configuration from `\sprout.toml` in the root of the EFI partition it was loaded from.
This is a configuration that boots a Linux kernel from the EFI partition:
```toml
# sprout configuration: version 1
version = 1
# add a boot entry for booting linux
# which will run the boot-linux action.
[entries.boot-linux]
title = "Boot Linux"
actions = ["boot-linux"]
# use the chainload action to boot linux via the efi stub.
# the options below are passed to the efi stub as the
# kernel command line. the initrd is loaded using the efi stub
# initrd loader mechanism.
[actions.boot-linux]
chainload.path = "\\vmlinuz"
chainload.options = ["root=/dev/sda1"]
chainload.linux-initrd = "\\initrd"
```
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
version = 1
[drivers.ext4]
path = "\\sprout\\drivers\\ext4.efi"
[options]
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
[Code of Conduct]: ./CODE_OF_CONDUCT.md
[Security Policy]: ./SECURITY.md
Sprout website at https://sprout.edera.dev
-36
View File
@@ -1,36 +0,0 @@
# 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.
-11
View File
@@ -1,11 +0,0 @@
# Security Policy
## Reporting Security Issues
The sprout team and community take security bugs in sprout seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
To report a security issue, you can use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/edera-dev/sprout/security/advisories/new) tab.
You may also report security issues to security@edera.dev if that is the preferred method of reporting.
The sprout team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

File renamed without changes.
-18
View File
@@ -1,18 +0,0 @@
# 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
@@ -1,148 +0,0 @@
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
@@ -1,159 +0,0 @@
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
@@ -1,273 +0,0 @@
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(),
}
}
-31
View File
@@ -1,31 +0,0 @@
[package]
name = "edera-sprout-boot"
description = "Sprout: Modern UEFI Bootloader"
license.workspace = true
version.workspace = true
homepage.workspace = true
repository.workspace = true
edition.workspace = true
[dependencies]
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
uefi.workspace = true
uefi-raw.workspace = true
[build-dependencies]
edera-sprout-build.path = "../build"
[[bin]]
name = "sprout"
path = "src/main.rs"
test = false
-3
View File
@@ -1,3 +0,0 @@
# Sprout Bootloader
The main bootable crate of the Sprout bootloader.
-10
View File
@@ -1,10 +0,0 @@
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();
}
-42
View File
@@ -1,42 +0,0 @@
use crate::context::SproutContext;
use alloc::rc::Rc;
use anyhow::{Context, Result, bail};
/// EFI chainloader action.
pub mod chainload;
/// Edera hypervisor action.
pub mod edera;
/// EFI console print action.
pub mod print;
/// Execute the action specified by `name` which should be stored in the
/// root context of the provided `context`. This function may not return
/// if the provided action executes an operating system or an EFI application
/// that does not return control to sprout.
pub fn execute(context: Rc<SproutContext>, name: impl AsRef<str>) -> Result<()> {
// Retrieve the action from the root context.
let Some(action) = context.root().actions().get(name.as_ref()) else {
bail!("unknown action '{}'", name.as_ref());
};
// Finalize the context and freeze it.
let context = context
.finalize()
.context("unable to finalize context")?
.freeze();
// Execute the action.
if let Some(chainload) = &action.chainload {
chainload::chainload(context.clone(), chainload)?;
return Ok(());
} else if let Some(print) = &action.print {
print::print(context.clone(), print)?;
return Ok(());
} else if let Some(edera) = &action.edera {
edera::edera(context.clone(), edera)?;
return Ok(());
}
// If we reach here, we don't know how to execute the action that was configured.
// This is likely unreachable, but we should still return an error just in case.
bail!("unknown action configuration");
}
-269
View File
@@ -1,269 +0,0 @@
use crate::context::SproutContext;
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 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("unable to resolve chainload path")?;
// Create a new image load request with the current image and the resolved path.
let request = ImageLoadRequest::new(sprout_image, ImageSource::ResolvedPath(&resolved));
// Load the image to chainload using the image loader support module.
// It will determine if the image needs to be loaded via the shim or can be loaded directly.
let image = ImageLoader::load(request)?;
// Unload the image if an error occurs before it is started.
let mut unload_guard = UnloadGuard(Some(*image.handle()));
// Stamp and combine the options to pass to the image.
let options = combine_options(context.stamp_iter(configuration.options.iter()));
// Pass the load options to the image.
// If no options are provided, the resulting string will be empty.
// The options are pinned and boxed to ensure that they are valid for the lifetime of this
// function, which ensures the lifetime of the options for the image runtime.
let options = Box::pin(
CString16::try_from(&options[..])
.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");
}
// 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 paths. A single linux-initrd is read the same way as a chain of one.
let initrd_paths = configuration
.linux_initrd
.iter()
.chain(configuration.linux_initrd_chain.iter())
.map(|item| context.stamp(item));
// A BLS entry can have more initrds than the chain lists. Those would be dropped without a
// word, and then the kernel may not find its root, so say so.
let chain_length = configuration.linux_initrd_chain.len();
if chain_length > 0 {
for slot in chain_length..BLS_INITRD_SLOTS {
let placeholder = format!("$initrd-{}", slot);
let value = context.stamp(&placeholder);
if !value.is_empty() && value != placeholder {
warn!(
"the entry has an initrd for slot {}, but linux-initrd-chain only lists {}, \
so it is not loaded",
slot, chain_length
);
break;
}
}
}
// Read each initrd and concatenate the contents in order.
// Paths that are empty after stamping are skipped.
let mut initrd: Option<Vec<u8>> = None;
for path in initrd_paths {
let Some(path) = empty_is_none(Some(path)) else {
continue;
};
let Some(content) = read_initrd(&context, &path)? else {
continue;
};
match initrd.as_mut() {
Some(initrd) => append_initrd(initrd, &content),
None => initrd = Some(content),
}
}
// If an initrd was read, register it with the EFI stack.
// An empty initrd is not registered, as Linux fails to boot if the initrd it is
// given can't be loaded, instead of booting without one.
let mut initrd_handle = None;
if let Some(content) = initrd.filter(|content| !content.is_empty()) {
let handle =
MediaLoaderHandle::register(LINUX_EFI_INITRD_MEDIA_GUID, content.into_boxed_slice())
.context("unable to register linux initrd")?;
initrd_handle = Some(handle);
}
// 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.
// This is only informational, so it should not prevent booting.
if let Err(error) = BootloaderInterface::mark_exec(context.root().timer()) {
warn!(
"unable to mark execution of boot entry in bootloader interface: {:#}",
error
);
}
// Since we are about to hand off control to another image, we need to execute the handoff hook.
// This will perform operations like clearing the screen.
before_handoff(&context).context("unable to execute before handoff hook")?;
// The image is about to be started, so it is no longer ours to unload.
// Once started, the image is unloaded by the firmware when it exits.
unload_guard.0 = None;
// Start the loaded image.
// This call might return, or it may pass full control to another image that will never return.
// Capture the result to ensure we can return an error if the image fails to start, but only
// after the optional initrd has been unregistered.
let result = uefi::boot::start_image(*image.handle());
// 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(())
}
-144
View File
@@ -1,144 +0,0 @@
use crate::{actions, context::SproutContext};
use alloc::rc::Rc;
use alloc::vec::Vec;
use alloc::{format, vec};
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 eficore::platform::tpm::PlatformTpm;
use uefi::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_bytes(
guid: Guid,
what: &str,
bytes: Vec<u8>,
) -> Result<MediaLoaderHandle> {
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);
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<()> {
// The initrd can be provided as either a single initrd or a chain, but not both.
if configuration.initrd.is_some() && !configuration.initrd_chain.is_empty() {
bail!("initrd and initrd-chain cannot be used together");
}
// Only register the initrd media loader if the user actually configured one.
let xen_opts = combine_options(context.stamp_iter(configuration.xen_options.iter()));
let dom0_args = combine_options(context.stamp_iter(configuration.kernel_options.iter()));
// 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 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")?;
// 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 !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_handle);
}
// Chainload to the Xen EFI stub.
let result = actions::chainload::chainload(
context.clone(),
&ChainloadConfiguration {
path: configuration.xen.clone(),
options: vec![],
linux_initrd: None,
linux_initrd_chain: vec![],
devicetree: None,
},
)
.context("unable to chainload to xen");
// Explicitly drop the media loaders to clarify when they should be unregistered.
drop(media_loaders);
result
}
-11
View File
@@ -1,11 +0,0 @@
use crate::context::SproutContext;
use alloc::rc::Rc;
use anyhow::Result;
use edera_sprout_config::actions::print::PrintConfiguration;
use log::info;
/// Executes the print action with the specified `configuration` inside the provided `context`.
pub fn print(context: Rc<SproutContext>, configuration: &PrintConfiguration) -> Result<()> {
info!("{}", context.stamp(&configuration.text));
Ok(())
}
-158
View File
@@ -1,158 +0,0 @@
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.
pub mod bls;
/// linux: autodetect and configure Linux kernels.
/// This autoconfiguration module should not be activated
/// on BLS-enabled filesystems as it may make duplicate entries.
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.
/// 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.
// 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 = 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);
// 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 && let Err(error) = linux::scan(&mut filesystem, &root, config) {
warn!(
"unable to scan filesystem for linux configurations: {:#}",
error
);
}
// Always look 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(())
}
-164
View File
@@ -1,164 +0,0 @@
use crate::generators::bls::BLS_INITRD_SLOTS;
use alloc::string::ToString;
use alloc::{format, vec};
use anyhow::{Context, Result};
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_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;
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
/// The name prefix of the BLS chainload action that will be used
/// by the BLS generator to chainload entries.
const BLS_CHAINLOAD_ACTION_PREFIX: &str = "bls-chainload-";
/// 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"));
// 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
.try_exists(bls_loader_conf_path)
.context("unable to check for BLS loader.conf file")?;
// Whether we have an entries directory.
// We actually iterate the entries to see if there are any 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)
.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 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,);
// BLS is now detected, generate a configuration for it.
let generator = BlsConfiguration {
entry: EntryDeclaration {
title: "$title".to_string(),
actions: vec![chainload_action_name.clone()],
..Default::default()
},
path: format!("{}\\loader", root),
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.
config.generators.insert(
format!("auto-bls-{}", root_unique_hash),
GeneratorDeclaration {
bls: Some(generator),
..Default::default()
},
);
// 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", entry_root),
options: vec!["$options".to_string()],
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.
config.actions.insert(
chainload_action_name,
ActionDeclaration {
chainload: Some(chainload),
..Default::default()
},
);
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)
}
-254
View File
@@ -1,254 +0,0 @@
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use alloc::{format, vec};
use anyhow::{Context, Result};
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_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;
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
/// The name prefix of the Linux chainload action that will be used to boot Linux.
const LINUX_CHAINLOAD_ACTION_PREFIX: &str = "linux-chainload-";
/// The locations to scan for kernel pairs.
/// A location that doesn't exist or can't be read is skipped.
/// The "\\" location represents the root of the filesystem.
const SCAN_LOCATIONS: &[&str] = &["\\boot", "\\"];
/// This is really silly, but if what we are booting is the Canonical stubble stub,
/// 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
/// have something in it. Canonical's stubble copied code from systemd that does this
/// 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>
/// Then this code asserts on it, stopping the boot process:
/// <https://github.com/ubuntu/stubble/blob/e56643979addfb98982266018e08921c07424a0c/stub.c#L27>
const DEFAULT_LINUX_OPTIONS: &str = "placeholder";
/// Pair of kernel and initramfs.
/// This is what scanning a directory is meant to find.
struct KernelPair {
/// The path to a kernel.
kernel: String,
/// The path to an initramfs, if any.
initramfs: Option<String>,
}
/// Scan the specified `filesystem` at `path` for [KernelPair] results.
fn scan_directory(filesystem: &mut FileSystem, path: &str) -> Result<Vec<KernelPair>> {
// All the discovered kernel pairs.
let mut pairs = Vec::new();
// We have to special-case the root directory due to path logic in the uefi crate.
let is_root = path.is_empty() || path == "\\";
// Construct a filesystem path from the path string.
let path = CString16::try_from(path).context("unable to convert path to CString16")?;
let path = Path::new(&path);
let path = path.to_path_buf();
// Check if the path exists and is a directory.
let exists = filesystem
.metadata(&path)
.ok()
.map(|metadata| metadata.is_directory())
.unwrap_or(false);
// If the path does not exist, return an empty list.
if !exists {
return Ok(pairs);
}
// Open a directory iterator on the path to scan.
// Ignore errors here as in some scenarios this might fail due to symlinks.
let Some(directory) = filesystem.read_dir(&path).ok() else {
return Ok(pairs);
};
// Create a new path used for joining file names below.
// All attempts to derive paths for the files in the directory should use this instead.
// The uefi crate does not handle push correctly for the root directory.
// It will add a second slash, which will cause our path logic to fail.
let path_for_join = if is_root {
PathBuf::new()
} else {
path.clone()
};
// For each item in the directory, find a kernel.
for item in directory {
let item = item.context("unable to read directory item")?;
// Skip over any items that are not regular files.
if !item.is_regular_file() {
continue;
}
// Convert the name from a CString16 to a String.
let name = item.file_name().to_string();
// Convert the name to lowercase to make all of this case-insensitive.
let name_for_match = name.to_lowercase();
// Find a kernel prefix that matches, if any.
// This is case-insensitive to ensure we pick up all possibilities.
let Some(prefix) = match_kernel_prefix(&name_for_match, LINUX_KERNEL_PREFIXES) else {
// Skip over anything that doesn't match a kernel prefix.
continue;
};
// Acquire the suffix of the name, this will be used to match an initramfs.
let suffix = &name[prefix.len()..];
// 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(candidate) = candidates.next() else {
break None;
};
// Construct an initramfs path.
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 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
.metadata(&initramfs_path)
.is_ok_and(|metadata| metadata.is_regular_file())
{
break Some(initramfs_path);
}
};
// Construct a kernel path from the kernel name.
let mut kernel = path_for_join.clone();
kernel.push(Path::new(&item.file_name()));
let kernel = kernel.to_string();
let initramfs = matched_initramfs_path.map(|initramfs_path| initramfs_path.to_string());
// Produce a kernel pair.
let pair = KernelPair { kernel, initramfs };
pairs.push(pair);
}
Ok(pairs)
}
/// Scan the specified `filesystem` for Linux kernels and matching initramfs.
pub fn scan(
filesystem: &mut FileSystem,
root: &DevicePath,
config: &mut RootConfiguration,
) -> Result<bool> {
let mut pairs = Vec::new();
// 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);
// 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 {
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.
if pairs.is_empty() {
return Ok(false);
}
// Generate a unique name for the linux chainload action.
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
.into_iter()
.map(|pair| {
BTreeMap::from_iter(vec![
("name".to_string(), pair.kernel.clone()),
("kernel".to_string(), format!("{}{}", root, pair.kernel)),
(
"initrd".to_string(),
pair.initramfs
.map(|initramfs| format!("{}{}", root, initramfs))
.unwrap_or_default(),
),
])
})
.collect(),
};
// Generate a unique name for the Linux generator and insert the generator into the configuration.
config.generators.insert(
format!("auto-linux-{}", root_unique_hash),
GeneratorDeclaration {
list: Some(generator),
..Default::default()
},
);
// Insert a default value for the linux-options if it doesn't exist.
if !config.values.contains_key("linux-options") {
config.values.insert(
"linux-options".to_string(),
DEFAULT_LINUX_OPTIONS.to_string(),
);
}
// Generate a chainload configuration for the list generator.
// The list will provide these values to us.
// Note that we don't need an extra \\ in the paths here.
// The root already contains a trailing slash.
let chainload = ChainloadConfiguration {
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.
config.actions.insert(
chainload_action_name,
ActionDeclaration {
chainload: Some(chainload),
..Default::default()
},
);
// We had a Linux kernel, so return true to indicate something was found.
Ok(true)
}
-83
View File
@@ -1,83 +0,0 @@
use alloc::string::ToString;
use alloc::{format, vec};
use anyhow::{Context, Result};
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;
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
/// The name prefix of the Windows chainload action that will be used to boot Windows.
const WINDOWS_CHAINLOAD_ACTION_PREFIX: &str = "windows-chainload-";
/// Windows boot manager path.
const BOOTMGR_FW_PATH: &str = "\\EFI\\Microsoft\\Boot\\bootmgfw.efi";
/// Scan the specified `filesystem` for Windows configurations.
pub fn scan(
filesystem: &mut FileSystem,
root: &DevicePath,
config: &mut RootConfiguration,
) -> Result<bool> {
// Convert the boot manager firmware path to a path.
let bootmgr_fw_path =
CString16::try_from(BOOTMGR_FW_PATH).context("unable to convert path to CString16")?;
let bootmgr_fw_path = Path::new(&bootmgr_fw_path);
// Check if the boot manager firmware path exists, if it doesn't, return false.
if !filesystem
.try_exists(bootmgr_fw_path)
.context("unable to check if bootmgr firmware path exists")?
{
return Ok(false);
}
// 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 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);
// 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);
// Generate a chainload configuration for Windows.
let chainload = ChainloadConfiguration {
path: format!("{}{}", root, bootmgr_fw_path),
options: vec![],
..Default::default()
};
// Insert the chainload action into the configuration.
config.actions.insert(
chainload_action_name,
ActionDeclaration {
chainload: Some(chainload),
..Default::default()
},
);
// We have a Windows boot entry, so return true to indicate something was found.
Ok(true)
}
-104
View File
@@ -1,104 +0,0 @@
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)
}
}
-2
View File
@@ -1,2 +0,0 @@
/// The configuration loader mechanisms.
pub mod loader;
-131
View File
@@ -1,131 +0,0 @@
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, warn};
use serde_ignored::Path;
use toml::Value;
use uefi::proto::device_path::LoadedImageDevicePath;
/// Loads the raw configuration from the sprout config file as data.
fn load_raw_config(options: &SproutOptions) -> Result<Vec<u8>> {
// Open the LoadedImageDevicePath protocol to get the path to the current image.
let current_image_device_path_protocol =
uefi::boot::open_protocol_exclusive::<LoadedImageDevicePath>(uefi::boot::image_handle())
.context("unable to get loaded image device path")?;
// Acquire the device path as a boxed device path.
let path = current_image_device_path_protocol.deref().to_boxed();
info!("configuration file: {}", options.config);
// Read the contents of the sprout config file.
let content = eficore::path::read_file_contents(Some(&path), &options.config)
.context("unable to read sprout config file")?;
// Measure the sprout.toml into the TPM, if needed and possible.
PlatformTpm::log_event(
PlatformTpm::PCR_BOOT_LOADER_CONFIG,
&content,
"sprout: configuration file",
)
.context("unable to measure the sprout.toml file into the TPM")?;
// Return the contents of the sprout config file.
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.
let content = load_raw_config(options)?;
// Parse the raw configuration into a toml::Value which can represent any TOML file.
let value: Value = toml::from_slice(&content).context("unable to parse sprout config file")?;
// Check the version of the configuration without parsing the full configuration.
let version = value
.get("version")
.cloned()
.unwrap_or_else(|| Value::Integer(latest_version() as i64));
// Parse the version into an u32.
let version: u32 = version
.try_into()
.context("unable to get configuration version")?;
// Check if the version is supported.
if version != latest_version() {
bail!("unsupported configuration version: {}", version);
}
// If the version is supported, parse the full configuration.
// 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)
}
-265
View File
@@ -1,265 +0,0 @@
use crate::options::SproutOptions;
use alloc::boxed::Box;
use alloc::collections::{BTreeMap, BTreeSet};
use alloc::rc::Rc;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use anyhow::anyhow;
use anyhow::{Result, bail};
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 {
/// The actions that are available in Sprout.
actions: BTreeMap<String, ActionDeclaration>,
/// The device path of the loaded Sprout image.
loaded_image_path: Option<Box<DevicePath>>,
/// Platform timer started at the beginning of the boot process.
timer: PlatformTimer,
/// The global options of Sprout.
options: SproutOptions,
}
impl RootContext {
/// Creates a new root context with the `loaded_image_device_path` which will be stored
/// in the context for easy access. We also provide a `timer` which is used to measure elapsed
/// time for the bootloader.
pub fn new(
loaded_image_device_path: Box<DevicePath>,
timer: PlatformTimer,
options: SproutOptions,
) -> Self {
Self {
actions: BTreeMap::new(),
timer,
loaded_image_path: Some(loaded_image_device_path),
options,
}
}
/// Access the actions configured inside Sprout.
pub fn actions(&self) -> &BTreeMap<String, ActionDeclaration> {
&self.actions
}
/// Access the actions configured inside Sprout mutably for modification.
pub fn actions_mut(&mut self) -> &mut BTreeMap<String, ActionDeclaration> {
&mut self.actions
}
/// Access the platform timer that is started at the beginning of the boot process.
pub fn timer(&self) -> &PlatformTimer {
&self.timer
}
/// Access the device path of the loaded Sprout image.
pub fn loaded_image_path(&self) -> Result<&DevicePath> {
self.loaded_image_path
.as_deref()
.ok_or_else(|| anyhow!("no loaded image path"))
}
/// Access the global Sprout options.
pub fn options(&self) -> &SproutOptions {
&self.options
}
}
/// A context of Sprout. This is passed around different parts of Sprout and represents
/// a [RootContext] which is data that is shared globally, and [SproutContext] which works
/// sort of like a tree of values. You can cheaply clone a [SproutContext] and modify it with
/// new values, which override the values of contexts above it.
///
/// This is a core part of the value mechanism in Sprout which makes templating possible.
pub struct SproutContext {
root: Rc<RootContext>,
parent: Option<Rc<SproutContext>>,
values: BTreeMap<String, String>,
}
impl SproutContext {
/// Create a new [SproutContext] using `root` as the root context.
pub fn new(root: RootContext) -> Self {
Self {
root: Rc::new(root),
parent: None,
values: BTreeMap::new(),
}
}
/// Access the root context of this context.
pub fn root(&self) -> &RootContext {
self.root.as_ref()
}
/// Access the root context to modify it, if possible.
pub fn root_mut(&mut self) -> Option<&mut RootContext> {
Rc::get_mut(&mut self.root)
}
/// Retrieve the value specified by `key` from this context or its parents.
/// Returns `None` if the value is not found.
pub fn get(&self, key: impl AsRef<str>) -> Option<&String> {
self.values.get(key.as_ref()).or_else(|| {
self.parent
.as_ref()
.and_then(|parent| parent.get(key.as_ref()))
})
}
/// Collects all keys that are present in this context or its parents.
/// This is useful for iterating over all keys in a context.
pub fn all_keys(&self) -> Vec<String> {
let mut keys = BTreeSet::new();
for key in self.values.keys() {
keys.insert(key.clone());
}
if let Some(parent) = &self.parent {
keys.extend(parent.all_keys());
}
keys.into_iter().collect()
}
/// Collects all values that are present in this context or its parents.
/// This is useful for iterating over all values in a context.
pub fn all_values(&self) -> BTreeMap<String, String> {
let mut values = BTreeMap::new();
for key in self.all_keys() {
// Acquire the value from the context. Since retrieving all the keys will give us
// a full view of the context, we can be sure that the key exists.
let value = self.get(&key).cloned().unwrap_or_default();
values.insert(key.clone(), value);
}
values
}
/// Sets the value `key` to the value specified by `value` in this context.
/// If the parent context has this key, this will override that key.
pub fn set(&mut self, key: impl AsRef<str>, value: impl ToString) {
self.values
.insert(key.as_ref().to_string(), value.to_string());
}
/// Inserts all the specified `values` into this context.
/// These values will take precedence over its parent context.
pub fn insert(&mut self, values: &BTreeMap<String, String>) {
for (key, value) in values {
self.values.insert(key.clone(), value.clone());
}
}
/// Forks this context as an owned [SproutContext]. This makes it possible
/// to cheaply modify a context without cloning the parent context map.
/// The parent of the returned context is [self].
pub fn fork(self: &Rc<SproutContext>) -> Self {
Self {
root: self.root.clone(),
parent: Some(self.clone()),
values: BTreeMap::new(),
}
}
/// Freezes this context into a [Rc] which makes it possible to cheaply clone
/// and makes it less easy to modify a context. This can be used to pass the context
/// to various other parts of Sprout and ensure it won't be modified. Instead, once
/// a context is frozen, it should be [self.fork]'d to be modified.
pub fn freeze(self) -> Rc<SproutContext> {
Rc::new(self)
}
/// Finalizes a context by producing a context with no parent that contains all the values
/// of all parent contexts merged. This makes it possible to ensure [SproutContext] has no
/// inheritance with other [SproutContext]s. It will still contain a [RootContext] however.
pub fn finalize(&self) -> Result<SproutContext> {
// Collect all the values from the context and its parents.
let mut current_values = self.all_values();
// To ensure that there is no possible infinite loop, we need to check
// the number of iterations. If it exceeds CONTEXT_FINALIZE_ITERATION_LIMIT, we bail.
let mut iterations: usize = 0;
loop {
iterations += 1;
if iterations > CONTEXT_FINALIZE_ITERATION_LIMIT {
bail!("maximum number of replacement iterations reached while finalizing context");
}
let mut did_change = false;
let mut values = BTreeMap::new();
for (key, value) in &current_values {
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);
}
current_values = values;
// If the values did not change, we can stop.
if !did_change {
break;
}
}
// Produce the final context.
Ok(Self {
root: self.root.clone(),
parent: None,
values: current_values,
})
}
/// 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 {
// 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
/// may not succeed if something else is holding onto the value.
pub fn unload(self: Rc<SproutContext>) -> Option<SproutContext> {
Rc::into_inner(self)
}
}
-190
View File
@@ -1,190 +0,0 @@
use crate::context::SproutContext;
use alloc::collections::BTreeMap;
use alloc::format;
use alloc::rc::Rc;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use anyhow::{Context, Result};
use edera_sprout_config::drivers::{DriverDeclaration, DriverProvides};
use eficore::loader::source::ImageSource;
use eficore::loader::{ImageLoadRequest, ImageLoader};
use log::info;
use uefi::boot::{OpenProtocolAttributes, OpenProtocolParams, 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<Handle> {
// Acquire the handle and device path of the loaded image.
let sprout_image = uefi::boot::image_handle();
// Resolve the path to the driver image.
let resolved = eficore::path::resolve_path(
Some(context.root().loaded_image_path()?),
context.stamp(&driver.path),
)
.context("unable to resolve path to driver")?;
// Create an image load request with the current image and the resolved path.
let request = ImageLoadRequest::new(sprout_image, ImageSource::ResolvedPath(&resolved));
// Load the driver image using the image loader support module.
// It will determine if the image needs to be loaded via the shim or can be loaded directly.
let image = ImageLoader::load(request)?;
// Start the driver image, this is expected to return control to sprout.
// There is no guarantee that the driver will actually return control as it is
// just a standard EFI image.
info!("starting driver image");
uefi::boot::start_image(*image.handle()).context("unable to start driver image")?;
info!("driver image returned");
Ok(*image.handle())
}
/// Checks whether the handle is a logical partition rather than a whole disk.
fn is_partition(handle: Handle) -> bool {
let params = OpenProtocolParams {
handle,
agent: uefi::boot::image_handle(),
controller: None,
};
// Opening with GetProtocol does not disturb the existing protocol users.
let block = unsafe {
uefi::boot::open_protocol::<BlockIO>(params, OpenProtocolAttributes::GetProtocol)
};
block.is_ok_and(|block| block.media().is_logical_partition())
}
/// Locates the handles that are disks or partitions.
/// Filesystem drivers bind to partitions, so whole disks are only returned when
/// there are no partitions. Reconnecting a whole disk makes the firmware rebuild
/// its partition handles, which hangs on some NVMe firmware.
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);
}
}
}
let partitions: Vec<Handle> = storage
.iter()
.copied()
.filter(|handle| is_partition(*handle))
.collect();
if partitions.is_empty() {
return Ok(storage);
}
Ok(partitions)
}
/// Describes a handle by its device path, for diagnosing hangs.
fn describe(handle: Handle) -> String {
use uefi::proto::device_path::DevicePath;
let params = OpenProtocolParams {
handle,
agent: uefi::boot::image_handle(),
controller: None,
};
match unsafe {
uefi::boot::open_protocol::<DevicePath>(params, OpenProtocolAttributes::GetProtocol)
} {
Ok(path) => path.to_string(),
Err(_) => String::from("<no device path>"),
}
}
/// 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, drivers: &[Handle]) -> 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 => {
// Probe in reverse so the first partition is the last to be tried.
let mut storage = locate_storage_handles()?;
storage.reverse();
storage
}
};
// The driver list passed to the firmware must end with a null entry.
let driver_list: Vec<Option<Handle>> = drivers
.iter()
.copied()
.map(Some)
.chain(core::iter::once(None))
.collect();
let total = handles.len();
for (index, handle) in handles.into_iter().enumerate() {
info!("reconnecting {}/{}: {}", index + 1, total, describe(handle));
// 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.
// Filesystem drivers are connected directly so the firmware stack is not rebuilt.
let _ = match provides {
DriverProvides::Any => uefi::boot::connect_controller(handle, &[], None, true),
DriverProvides::Filesystems => {
uefi::boot::connect_controller(handle, &driver_list, None, false)
}
};
info!("reconnected {}/{}", index + 1, total);
}
Ok(())
}
/// Load all the drivers specified in `drivers`.
/// 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>,
) -> Result<()> {
// If there are no drivers, we don't need to do anything.
if drivers.is_empty() {
return Ok(());
}
info!("loading drivers");
// Load all the drivers in no particular order.
let mut loaded = Vec::new();
for (name, driver) in drivers {
loaded.push(
load_driver(context.clone(), driver)
.context(format!("unable to load driver: {}", name))?,
);
}
// 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, &loaded).context("unable to reconnect drivers")?;
info!("loaded drivers");
// We've now loaded all the drivers, so we can return.
Ok(())
}
-249
View File
@@ -1,249 +0,0 @@
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)]
pub struct BootableEntry {
name: String,
title: String,
context: Rc<SproutContext>,
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 {
/// Create a new bootable entry to represent the full context of an entry.
pub fn new(
name: String,
title: String,
context: Rc<SproutContext>,
declaration: EntryDeclaration,
) -> Self {
Self {
name,
title,
context,
declaration,
default: false,
pin_name: false,
sort_key: None,
boot_counter: None,
id_suffix: None,
id_profile: None,
extra_profile: false,
}
}
/// Fetch the name of the entry. This is usually a machine-identifiable key.
pub fn name(&self) -> &str {
&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
}
/// Fetch the full context of the entry.
pub fn context(&self) -> Rc<SproutContext> {
Rc::clone(&self.context)
}
/// Fetch the declaration of the entry.
pub fn declaration(&self) -> &EntryDeclaration {
&self.declaration
}
/// Fetch whether the entry is the default entry.
pub fn is_default(&self) -> bool {
self.default
}
/// Fetch whether the entry is pinned, which prevents prefixing.
pub fn is_pin_name(&self) -> bool {
self.pin_name
}
/// Swap out the context of the entry.
pub fn swap_context(&mut self, context: Rc<SproutContext>) {
self.context = context;
}
/// Restamp the title with the current context.
pub fn restamp_title(&mut self) {
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;
}
// Unmark this entry as the default entry.
pub fn unmark_default(&mut self) {
self.default = false;
}
/// Mark this entry as being pinned, which prevents prefixing.
pub fn mark_pin_name(&mut self) {
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 {
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`.
/// This will search for an entry by name, title, or index.
pub fn find<'a>(
needle: &str,
haystack: impl Iterator<Item = &'a BootableEntry>,
) -> Option<&'a BootableEntry> {
haystack
.enumerate()
.find(|(index, entry)| entry.is_match(needle) || index.to_string() == needle)
.map(|(_index, entry)| entry)
}
}
-19
View File
@@ -1,19 +0,0 @@
use crate::context::SproutContext;
use alloc::rc::Rc;
use alloc::string::String;
use anyhow::{Result, bail};
use edera_sprout_config::extractors::ExtractorDeclaration;
/// The filesystem device match extractor.
pub mod filesystem_device_match;
/// Extracts the value using the specified `extractor` under the provided `context`.
/// The extractor must return a value, and if a value cannot be determined, an error
/// should be returned.
pub fn extract(context: Rc<SproutContext>, extractor: &ExtractorDeclaration) -> Result<String> {
if let Some(filesystem) = &extractor.filesystem_device_match {
filesystem_device_match::extract(context, filesystem)
} else {
bail!("unknown extractor configuration");
}
}
@@ -1,178 +0,0 @@
use crate::context::SproutContext;
use alloc::rc::Rc;
use alloc::string::String;
use anyhow::{Context, Result, anyhow, bail};
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, 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(
context: Rc<SproutContext>,
extractor: &FilesystemDeviceMatchExtractor,
) -> Result<String> {
// If no criteria are provided, bail with an error.
if extractor.has_label.is_none()
&& extractor.has_item.is_none()
&& extractor.has_partition_uuid.is_none()
&& extractor.has_partition_type_uuid.is_none()
{
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 {
// 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;
}
}
// If we have a match, return the device root path.
let path = uefi::boot::open_protocol_exclusive::<DevicePath>(handle)
.context("unable to open filesystem device path")?;
let path = path.deref();
// Acquire the device path root as a string.
return eficore::path::device_path_root(path).context("unable to get device path root");
}
// If there is a fallback value, use it at this point.
if let Some(fallback) = &extractor.fallback {
return Ok(fallback.clone());
}
// Without a fallback, we can't continue, so bail.
bail!("unable to find matching filesystem")
}
-125
View File
@@ -1,125 +0,0 @@
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;
/// The list generator.
pub mod list;
/// The matrix generator.
pub mod matrix;
/// Runs the generator kind specified by the `generator` option.
fn generate_base(
context: Rc<SproutContext>,
generator: &GeneratorDeclaration,
) -> Result<Vec<BootableEntry>> {
if let Some(matrix) = &generator.matrix {
matrix::generate(context, matrix)
} else if let Some(bls) = &generator.bls {
bls::generate(context, bls)
} else if let Some(list) = &generator.list {
list::generate(context, list)
} else {
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)
}
-589
View File
@@ -1,589 +0,0 @@
use crate::boot_counter::BootCounterTarget;
use crate::context::SproutContext;
use crate::entries::BootableEntry;
use alloc::{
format,
rc::Rc,
string::{String, ToString},
vec::Vec,
};
use anyhow::{Context, Result};
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 log::{info, warn};
use uefi::{
CString16, cstr16,
fs::{FileSystem, PathBuf},
proto::device_path::text::{AllowShortcuts, DisplayOnly},
proto::media::fs::SimpleFileSystem,
};
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 the initrd paths.
/// Fedora uses tuned which adds an initrd that shouldn't be used.
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>
fn sort_entries(a: &(BlsEntry, BootableEntry), b: &(BlsEntry, BootableEntry)) -> Ordering {
let (a_bls, a_boot) = a;
let (b_bls, b_boot) = b;
sort_bls(a_bls, a_boot.sort_name(), b_bls, b_boot.sort_name())
}
/// 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();
// 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")?;
// Construct a filesystem path to the BLS entries directory.
let mut entries_path = PathBuf::from(
bls_resolved
.sub_path
.to_string16(DisplayOnly(false), AllowShortcuts(false))
.context("unable to convert bls path to string")?,
);
entries_path.push(cstr16!("entries"));
// Open exclusive access to the BLS filesystem.
let fs =
uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(bls_resolved.filesystem_handle)
.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)
.context("unable to read bls entries")?;
// For each entry in the BLS entries directory, parse the entry and add it to the list.
for entry in entries_iter {
// Unwrap the entry file info.
let entry = entry.context("unable to read bls item entry")?;
// Skip items that are not regular files.
if !entry.is_regular_file() {
continue;
}
// Get the file name of the filesystem item.
let file_name = entry.file_name().to_string();
// 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;
}
// 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.
// 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 = 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 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;
}
// Ignore entries that are for another architecture.
if !entry.matches_architecture(ARCHITECTURE) {
continue;
}
// 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
);
}
// 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
);
}
// Put the initrds through a quirk modifier to support Fedora.
let initrds = quirk_initrd_remove_tuned(entry.initrd_paths());
// Skip entries with more initrds than there are slots, as they can't be fully loaded.
if initrds.len() > BLS_INITRD_SLOTS {
warn!(
"bls entry {} has more than {} initrds, skipping",
name, BLS_INITRD_SLOTS
);
continue;
}
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();
}
// 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())
}
-136
View File
@@ -1,136 +0,0 @@
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)
}
-37
View File
@@ -1,37 +0,0 @@
use crate::context::SproutContext;
use crate::entries::BootableEntry;
use alloc::rc::Rc;
use alloc::string::ToString;
use alloc::vec::Vec;
use anyhow::Result;
use edera_sprout_config::generators::list::ListConfiguration;
/// Generates a set of entries using the specified `list` configuration in the `context`.
pub fn generate(
context: Rc<SproutContext>,
list: &ListConfiguration,
) -> Result<Vec<BootableEntry>> {
let mut entries = Vec::new();
// For each combination, create a new context and entry.
for (index, combination) in list.values.iter().enumerate() {
let mut context = context.fork();
// Insert the combination into the context.
context.insert(combination);
let context = context.freeze();
// 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(),
entry.title.clone(),
context,
entry,
));
}
Ok(entries)
}
-26
View File
@@ -1,26 +0,0 @@
use crate::context::SproutContext;
use crate::entries::BootableEntry;
use crate::generators::list;
use alloc::rc::Rc;
use alloc::vec::Vec;
use anyhow::Result;
use edera_sprout_config::generators::list::ListConfiguration;
use edera_sprout_config::generators::matrix::MatrixConfiguration;
use edera_sprout_parsing::build_matrix;
/// Generates a set of entries using the specified `matrix` configuration in the `context`.
pub fn generate(
context: Rc<SproutContext>,
matrix: &MatrixConfiguration,
) -> Result<Vec<BootableEntry>> {
// Produce all the combinations of the input values.
let combinations = build_matrix(&matrix.values);
// Use the list generator to generate entries for each combination.
list::generate(
context,
&ListConfiguration {
entry: matrix.entry.clone(),
values: combinations,
},
)
}
-834
View File
@@ -1,834 +0,0 @@
#![doc = include_str!("../README.md")]
#![no_std]
#![no_main]
extern crate alloc;
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, 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, proto::device_path::LoadedImageDevicePath, runtime::ResetType};
use uefi_raw::Status;
/// actions: Code that can be configured and executed by Sprout.
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;
/// context: Stored values that can be cheaply forked and cloned.
pub mod context;
/// drivers: EFI drivers to load and provide extra functionality.
pub mod drivers;
/// entries: Boot menu entries that have a title and can execute actions.
pub mod entries;
/// extractors: Runtime code that can extract values into the Sprout context.
pub mod extractors;
/// generators: Runtime code that can generate entries with specific values.
pub mod generators;
/// menu: Display a boot menu to select an entry to boot.
pub mod menu;
/// options: Parse the options of the Sprout executable.
pub mod options;
/// phases: Hooks into specific parts of the boot process.
pub mod phases;
/// sbat: Secure Boot Attestation section.
pub mod sbat;
/// The delay to wait for when an error occurs in Sprout.
const DELAY_ON_ERROR: Duration = Duration::from_secs(10);
/// Unwraps the `result` of a bootloader interface operation, logging the error if one occurs.
/// The bootloader interface only exchanges information with the operating system, so a
/// firmware variable that can't be read or written should not prevent booting.
/// If an error occurs, the default value is provided instead.
fn advisory<T: Default>(result: Result<T>) -> T {
result.unwrap_or_else(|error| {
warn!("{:#}", error);
T::default()
})
}
/// 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.
advisory(
BootloaderInterface::mark_init(&timer)
.context("unable to mark initialization in bootloader interface"),
);
// Tell the bootloader interface what firmware we are running on.
advisory(
BootloaderInterface::set_firmware_info()
.context("unable to set firmware info in bootloader interface"),
);
// Tell the bootloader interface what loader is being used.
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.
// 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.
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")?;
// If --autoconfigure is specified, we use a stub configuration.
let mut config = if options.autoconfigure {
info!("autoconfiguration enabled, configuration file will be ignored");
RootConfiguration::default()
} else {
// Load the configuration of sprout.
// At this point, the configuration has been validated and the specified
// version is checked to ensure compatibility.
config::loader::load(&options)?
};
// Grab the sprout.efi loaded image path.
// This is done in a block to ensure the release of the LoadedImageDevicePath protocol.
let loaded_image_path = {
let current_image_device_path_protocol = uefi::boot::open_protocol_exclusive::<
LoadedImageDevicePath,
>(uefi::boot::image_handle())
.context("unable to get loaded image device path")?;
current_image_device_path_protocol.deref().to_boxed()
};
// Grab the partition GUID of the ESP that sprout was loaded from.
// 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"),
);
// 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.
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.
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);
// Insert the configuration actions into the root context.
root.actions_mut().extend(config.actions.clone());
// Create a new sprout context with the root context.
let mut context = SproutContext::new(root);
// Insert the configuration values into the sprout context.
context.insert(&config.values);
// Freeze the sprout context so it can be shared and cheaply cloned.
let context = context.freeze();
// Execute the early phase.
phase(context.clone(), &config.phases.early).context("unable to execute early phase")?;
// Load all configured drivers.
drivers::load(context.clone(), &config.drivers).context("unable to load drivers")?;
// If --autoconfigure is specified or the loaded configuration has autoconfigure enabled,
// trigger the autoconfiguration mechanism.
if context.root().options().autoconfigure || config.options.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.
let Some(mut context) = context.unload() else {
bail!("context safety violation while trying to unload context");
};
// Perform root context modification in a block to release the modification when complete.
{
// Modify the root context to include the autoconfigured actions.
let Some(root) = context.root_mut() else {
bail!("context safety violation while trying to modify root context");
};
// Extend the root context with the autoconfigured actions.
root.actions_mut().extend(config.actions);
// Insert any modified root values.
context.insert(&config.values);
}
// Refreeze the context to ensure that further operations can share the context.
let context = context.freeze();
// Run all the extractors declared in the configuration.
let mut extracted = BTreeMap::new();
for (name, extractor) in &config.extractors {
let value = extractors::extract(context.clone(), extractor)
.context(format!("unable to extract value {}", name))?;
info!("extracted value {}: {}", name, value);
extracted.insert(name.clone(), value);
}
let mut context = context.fork();
// Insert the extracted values into the sprout context.
context.insert(&extracted);
let context = context.freeze();
// Execute the startup phase.
phase(context.clone(), &config.phases.startup).context("unable to execute startup phase")?;
let mut entries = Vec::new();
// Insert all the static entries from the configuration into the entry list.
for (name, entry) in config.entries {
// Associate the main context with the static entry.
entries.push(BootableEntry::new(
name,
entry.title.clone(),
context.clone(),
entry,
));
}
// Run all the generators declared in the configuration.
for (name, generator) in config.generators {
let context = context.fork().freeze();
// Add all the entries generated by the generator to the entry list.
// The generator specifies the context associated with the entry.
entries.extend(generators::generate(&name, context, &generator)?);
}
// 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()
);
}
}
// 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 = 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 and sort key with any values.
entry.restamp_title();
entry.restamp_sort_key();
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.
advisory(
BootloaderInterface::set_entries(entries.iter().map(|entry| entry.id()))
.context("unable to set entries in bootloader interface"),
);
// 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 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 = 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 = advisory(
BootloaderInterface::get_oneshot_entry()
.context("unable to get bootloader interface oneshot entry"),
);
// If --boot is specified, boot that entry immediately.
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;
// 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();
// 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, and wait for the user to choose an entry.
force_boot_menu = true;
wait_for_user = true;
}
BootloaderInterfaceTimeout::MenuForceTimeout(timeout) => {
// Force the boot menu with the specified timeout.
force_boot_menu = true;
menu_timeout = timeout;
}
BootloaderInterfaceTimeout::MenuHidden => {
// 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 => {
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
);
}
}
}
// 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));
// 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)
};
// 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);
}
// 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(())
}
/// The main entrypoint of sprout.
/// It is possible this function will not return if actions that are executed
/// exit boot services or do not return control to sprout.
#[entry]
fn efi_main() -> Status {
// Initialize the basic UEFI environment.
// If initialization fails, we will return ABORTED.
// NOTE: This function will also initialize the logger.
// The logger will panic if it is unable to initialize.
// It is guaranteed that if this returns, the logger is initialized.
if let Err(error) = setup::init() {
error!("unable to initialize environment: {}", error);
return Status::ABORTED;
}
// Run Sprout, then handle the error.
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. 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;
}
// Sprout doesn't necessarily guarantee anything was booted.
// If we reach here, we will exit back to whoever called us.
Status::SUCCESS
}
-242
View File
@@ -1,242 +0,0 @@
use crate::entries::BootableEntry;
use crate::menu::basic::BasicMenu;
use crate::menu::graphical::GraphicalMenu;
use crate::menu::simple::SimpleMenu;
use alloc::vec;
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::warn;
use uefi::Event;
use uefi::ResultExt;
use uefi::boot::TimerTrigger;
use uefi::proto::console::text::{Input, Key};
use uefi_raw::table::boot::{EventType, Tpl};
/// basic: A menu that prints the entries and selects them by number.
pub mod basic;
/// 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 `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()
.context("unable to acquire key event")?;
// Timer event for timeout.
// SAFETY: The timer event creation allocated a timer pointer on the UEFI heap.
// This is validated safe as long as we are in boot services.
let timer_event = unsafe {
uefi::boot::create_event_ex(EventType::TIMER, Tpl::CALLBACK, None, None, None)
.context("unable to create timer event")?
};
let events = vec![timer_event, key_event];
// Wait for a key until the timer triggers.
// Store the result so that we can free the timer 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.
// This should always be called in practice as events are not modified by wait_for_event.
if let Some(timer_event) = events.into_iter().next() {
// 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 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 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 error from reading the key.
close_event_result?;
}
}
result
}
/// 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")?
};
// 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")?;
}
let index = uefi::boot::wait_for_event(&all)
.discard_errdata()
.context("unable to wait for event")?;
Ok((index != events.len()).then_some(index))
})();
// 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 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: Option<Duration>,
menu_disabled: bool,
style: MenuStyle,
graphical: &GraphicalMenuConfiguration,
entries: &'live [BootableEntry],
) -> Result<&'live BootableEntry> {
// 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
};
// 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
@@ -1,184 +0,0 @@
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
@@ -1,118 +0,0 @@
/// 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
@@ -1,957 +0,0 @@
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
@@ -1,2 +0,0 @@
// Include the generated logo in this file.
include!(concat!(env!("OUT_DIR"), "/logo.generated.rs"));
-280
View File
@@ -1,280 +0,0 @@
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))
})
}
}
-196
View File
@@ -1,196 +0,0 @@
use alloc::string::{String, ToString};
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";
/// The parsed options of sprout.
#[derive(Debug)]
pub struct SproutOptions {
/// Configures Sprout automatically based on the environment.
pub autoconfigure: bool,
/// Path to a configuration file to load.
pub config: String,
/// Entry to boot without showing the boot menu.
pub boot: Option<String>,
/// Force display of the boot menu.
pub force_menu: bool,
/// The timeout for the boot menu in seconds.
pub menu_timeout: Option<u64>,
/// The 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.
impl Default for SproutOptions {
fn default() -> Self {
Self {
autoconfigure: false,
config: DEFAULT_CONFIG_PATH.to_string(),
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 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 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"),
]);
// Acquire the arguments as determined by the UEFI core.
let mut args = eficore::env::args()?;
// 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()
);
}
}
}
}
}
-41
View File
@@ -1,41 +0,0 @@
use crate::actions;
use crate::context::SproutContext;
use alloc::format;
use alloc::rc::Rc;
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`.
/// 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<()> {
for item in phase {
let mut context = context.fork();
// Insert the values into the context.
context.insert(&item.values);
let context = context.freeze();
// Execute all the actions in this phase configuration.
for action in item.actions.iter() {
actions::execute(context.clone(), action)
.context(format!("unable to execute action '{}'", action))?;
}
}
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(())
}
-2
View File
@@ -1,2 +0,0 @@
sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
sprout,1,Edera,sprout,{version},https://sprout.edera.dev
1 sbat 1 SBAT Version sbat 1 https://github.com/rhboot/shim/blob/main/SBAT.md
2 sprout 1 Edera sprout {version} https://sprout.edera.dev
-2
View File
@@ -1,2 +0,0 @@
// Include the generated sbat section in this file.
include!(concat!(env!("OUT_DIR"), "/sbat.generated.rs"));
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "edera-sprout-build"
description = "Sprout Build Tools"
license.workspace = true
version.workspace = true
homepage.workspace = true
repository.workspace = true
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
-162
View File
@@ -1,162 +0,0 @@
use std::path::PathBuf;
use std::{env, fs};
/// Block size of the sbat section.
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;
if needed != data.len() {
data.resize(needed, 0);
}
}
/// Generate an .sbat link section module. This should be coupled with including the sbat module in
/// the crate that intends to embed the sbat section.
/// We intake a sbat.template.csv file in the calling crate and output a sbat.dat
/// which is included by a generated sbat.generated.rs file.
pub fn generate_sbat_module() {
// Notify Cargo that if the version changes, we need to regenerate the sbat.out file.
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
// The version of the package.
let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION not set");
// The output directory to place the sbat.csv into.
let output_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set"));
// The output path to the sbat.out file.
let out_file = output_dir.join("sbat.out");
// The output path to the sbat.generated.rs file.
let rs_file = output_dir.join("sbat.generated.rs");
// 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 path to the sbat.template.tsv file is in the source directory of the crate.
let sbat_template_file = crate_root.join("src/sbat.csv");
// Notify Cargo that if sbat.csv changes, we need to regenerate the sbat.out file.
println!(
"cargo:rerun-if-changed={}",
sbat_template_file
.to_str()
.expect("unable to convert sbat template path file to a string")
);
// Read the sbat.csv template file.
let sbat_template =
fs::read_to_string(&sbat_template_file).expect("unable to read sbat.csv file");
// Replace the version placeholder in the template with the actual version.
let sbat = sbat_template.replace("{version}", &version);
// Encode the sbat.csv as bytes.
let mut encoded = sbat.as_bytes().to_vec();
// Pad the sbat.csv to the required block size.
block_pad(&mut encoded, SBAT_BLOCK_SIZE);
// Write the sbat.out file to the output directory.
fs::write(&out_file, &encoded).expect("unable to write sbat.out");
// Generate the contents of the sbat.generated.rs file.
// The size must tbe size of the encoded sbat.out file.
let sbat_rs = SBAT_RS_TEMPLATE.replace("{size}", &encoded.len().to_string());
// 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
@@ -1,8 +0,0 @@
/// 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"));
-6
View File
@@ -1,6 +0,0 @@
/// 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>
/// NOTE: This data must be aligned by 512 bytes.
#[used]
#[unsafe(link_section = ".sbat")]
static SBAT: [u8; {size}] = *include_bytes!(concat!(env!("OUT_DIR"), "/sbat.out"));
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "edera-sprout-config"
description = "Sprout Configuration"
license.workspace = true
version.workspace = true
homepage.workspace = true
repository.workspace = true
edition.workspace = true
[dependencies.serde]
workspace = true
default-features = false
[lib]
name = "edera_sprout_config"
path = "src/lib.rs"
-32
View File
@@ -1,32 +0,0 @@
use serde::{Deserialize, Serialize};
/// Configuration for the chainload action.
pub mod chainload;
/// Configuration for the edera action.
pub mod edera;
/// Configuration for the print action.
pub mod print;
/// Declares an action that sprout can execute.
/// Actions allow configuring sprout's internal runtime mechanisms with values
/// that you can specify via other concepts.
///
/// Actions are the main work that Sprout gets done, like booting Linux.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ActionDeclaration {
/// Chainload to another EFI application.
/// This allows you to load any EFI application, either to boot an operating system
/// or to perform more EFI actions and return to sprout.
#[serde(default)]
pub chainload: Option<chainload::ChainloadConfiguration>,
/// Print a string to the EFI console.
#[serde(default)]
pub print: Option<print::PrintConfiguration>,
/// Boot the Edera hypervisor and the root operating system.
/// This action is an extension on top of the Xen EFI stub that
/// is specific to Edera.
#[serde(default, rename = "edera")]
pub edera: Option<edera::EderaConfiguration>,
}
-31
View File
@@ -1,31 +0,0 @@
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
/// The configuration of the chainload action.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ChainloadConfiguration {
/// The path to the image to chainload.
/// This can be a Linux EFI stub (vmlinuz usually) or a standard EFI executable.
pub path: String,
/// The options to pass to the image.
/// The options are concatenated by a space and then passed to the EFI application.
#[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.
/// 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>,
}
-28
View File
@@ -1,28 +0,0 @@
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
/// The configuration of the edera action which boots the Edera hypervisor.
/// Edera is based on Xen but modified significantly with a Rust stack.
/// Sprout is a component of the Edera stack and provides the boot functionality of Xen.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct EderaConfiguration {
/// The path to the Xen hypervisor EFI image.
pub xen: String,
/// The path to the kernel to boot for dom0.
pub kernel: String,
/// 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>,
/// The options to pass to the Xen hypervisor.
#[serde(default, rename = "xen-options")]
pub xen_options: Vec<String>,
}
-10
View File
@@ -1,10 +0,0 @@
use alloc::string::String;
use serde::{Deserialize, Serialize};
/// The configuration of the print action.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct PrintConfiguration {
/// The text to print to the console.
#[serde(default)]
pub text: String,
}
-30
View File
@@ -1,30 +0,0 @@
use alloc::string::String;
use serde::{Deserialize, Serialize};
/// Declares a driver configuration.
/// Drivers allow extending the functionality of Sprout.
/// Drivers are loaded at runtime and can provide extra functionality like filesystem support.
/// Drivers are loaded by their name, which is used to reference them in other concepts.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct DriverDeclaration {
/// The filesystem path to the driver.
/// This file should be an EFI executable that can be located and executed.
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,
}
-24
View File
@@ -1,24 +0,0 @@
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
/// Declares a boot entry to display in the boot menu.
///
/// Entries are the user-facing concept of Sprout, making it possible
/// to run a set of actions with a specific context.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct EntryDeclaration {
/// The title of the entry which will be display in the boot menu.
/// This is the pre-stamped value.
pub title: String,
/// The actions to run when the entry is selected.
#[serde(default)]
pub actions: Vec<String>,
/// 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>,
}
-18
View File
@@ -1,18 +0,0 @@
use crate::extractors::filesystem_device_match::FilesystemDeviceMatchExtractor;
use serde::{Deserialize, Serialize};
/// Configuration for the filesystem-device-match extractor.
pub mod filesystem_device_match;
/// Declares an extractor configuration.
/// Extractors allow calculating values at runtime
/// using built-in sprout modules.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ExtractorDeclaration {
/// The filesystem device match extractor.
/// This extractor finds a filesystem using some search criteria and returns
/// the device root path that can concatenated with subpaths to access files
/// on a particular filesystem.
#[serde(default, rename = "filesystem-device-match")]
pub filesystem_device_match: Option<FilesystemDeviceMatchExtractor>,
}
@@ -1,30 +0,0 @@
use alloc::string::String;
use serde::{Deserialize, Serialize};
/// The filesystem device match extractor.
/// This extractor finds a filesystem using some search criteria and returns
/// the device root path that can concatenated with subpaths to access files
/// on a particular filesystem.
/// The fallback value can be used to provide a value if no match is found.
///
/// This extractor requires all the criteria to match. If no criteria is provided,
/// an error is returned.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct FilesystemDeviceMatchExtractor {
/// Matches a filesystem that has the specified label.
#[serde(default, rename = "has-label")]
pub has_label: Option<String>,
/// Matches a filesystem that has the specified item.
/// An item is either a directory or file.
#[serde(default, rename = "has-item")]
pub has_item: Option<String>,
/// Matches a filesystem that has the specified partition UUID.
#[serde(default, rename = "has-partition-uuid")]
pub has_partition_uuid: Option<String>,
/// Matches a filesystem that has the specified partition type UUID.
#[serde(default, rename = "has-partition-type-uuid")]
pub has_partition_type_uuid: Option<String>,
/// The fallback value to use if no filesystem matches the criteria.
#[serde(default)]
pub fallback: Option<String>,
}
-72
View File
@@ -1,72 +0,0 @@
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.
pub mod bls;
/// Configuration for the list generator.
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)]
pub struct GeneratorDeclaration {
/// Matrix generator configuration.
/// Matrix allows you to specify multiple value-key values as arrays.
/// This allows multiplying the number of entries by any number of possible
/// configuration options. For example,
/// data.x = ["a", "b"]
/// data.y = ["c", "d"]
/// would generate an entry for each of these combinations:
/// x = a, y = c
/// x = a, y = d
/// x = b, y = c
/// x = b, y = d
#[serde(default)]
pub matrix: Option<MatrixConfiguration>,
/// BLS generator configuration.
/// BLS allows you to pass a filesystem path that contains a set of BLS entries.
/// It will generate a sprout entry for every supported BLS entry.
#[serde(default)]
pub bls: Option<BlsConfiguration>,
/// 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>>,
}
-144
View File
@@ -1,144 +0,0 @@
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, 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);
}
}
-18
View File
@@ -1,18 +0,0 @@
use crate::entries::EntryDeclaration;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
/// List generator configuration.
/// The list generator produces multiple entries based
/// on a set of input maps.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ListConfiguration {
/// The template entry to use for each generated entry.
#[serde(default)]
pub entry: EntryDeclaration,
/// The values to use as the input for the matrix.
#[serde(default)]
pub values: Vec<BTreeMap<String, String>>,
}
-18
View File
@@ -1,18 +0,0 @@
use crate::entries::EntryDeclaration;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
/// Matrix generator configuration.
/// The matrix generator produces multiple entries based
/// on input values multiplicatively.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct MatrixConfiguration {
/// The template entry to use for each generated entry.
#[serde(default)]
pub entry: EntryDeclaration,
/// The values to use as the input for the matrix.
#[serde(default)]
pub values: BTreeMap<String, Vec<String>>,
}
-15
View File
@@ -1,15 +0,0 @@
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>,
}
-257
View File
@@ -1,257 +0,0 @@
//! Sprout configuration descriptions.
//! This crate provides all the configuration structures for Sprout.
#![no_std]
extern crate alloc;
use crate::actions::ActionDeclaration;
use crate::drivers::DriverDeclaration;
use crate::entries::EntryDeclaration;
use crate::extractors::ExtractorDeclaration;
use crate::generators::GeneratorDeclaration;
use crate::phases::PhasesConfiguration;
use alloc::collections::BTreeMap;
use alloc::string::String;
use serde::{Deserialize, Serialize};
pub mod actions;
pub mod drivers;
pub mod entries;
pub mod extractors;
pub mod generators;
pub mod phases;
/// This is the latest version of the sprout configuration format.
/// This must be incremented when the configuration breaks compatibility.
pub const LATEST_VERSION: u32 = 1;
/// The default timeout for the boot menu in seconds.
pub const DEFAULT_MENU_TIMEOUT_SECONDS: u64 = 10;
/// The Sprout configuration format.
#[derive(Serialize, Deserialize, 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
/// the configuration is the latest version.
#[serde(default = "latest_version")]
pub version: u32,
/// 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>,
/// Drivers to load.
/// These drivers provide extra functionality like filesystem support to Sprout.
/// Each driver has a name which uniquely identifies it inside Sprout.
#[serde(default)]
pub drivers: BTreeMap<String, DriverDeclaration>,
/// Declares the extractors that add values to the sprout context that are calculated
/// at runtime. Each extractor has a name which corresponds to the value it will set
/// inside the sprout context.
#[serde(default)]
pub extractors: BTreeMap<String, ExtractorDeclaration>,
/// Declares the actions that can execute operations for sprout.
/// Actions are executable modules in sprout that take in specific structured values.
/// Actions are responsible for ensuring that passed strings are stamped to replace values
/// at runtime.
/// Each action has a name that can be referenced by other base concepts like entries.
#[serde(default)]
pub actions: BTreeMap<String, ActionDeclaration>,
/// Declares the entries that are displayed on the boot menu. These entries are static
/// but can still use values from the sprout context.
#[serde(default)]
pub entries: BTreeMap<String, EntryDeclaration>,
/// Declares the generators that are used to generate entries at runtime.
/// Each generator has its own logic for generating entries, but generally they intake
/// a template entry and stamp that template entry over some values determined at runtime.
/// Each generator has an associated name used to differentiate it across sprout.
#[serde(default)]
pub generators: BTreeMap<String, GeneratorDeclaration>,
/// Configures the various phases of sprout. This allows you to hook into specific parts
/// of the boot process to execute actions, for example, you can show a boot splash during
/// the early phase.
#[serde(default)]
pub phases: PhasesConfiguration,
}
/// Options configuration for Sprout, used when the corresponding options are not specified.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct OptionsConfiguration {
/// The entry to mark as the default entry, instead of the first entry.
#[serde(rename = "default-entry", default)]
pub default_entry: Option<String>,
/// 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.
pub fn latest_version() -> u32 {
LATEST_VERSION
}
#[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);
}
}
-32
View File
@@ -1,32 +0,0 @@
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
/// Configures the various phases of the boot process.
/// This allows hooking various phases to run actions.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct PhasesConfiguration {
/// The early phase is run before drivers are loaded.
#[serde(default)]
pub early: Vec<PhaseConfiguration>,
/// The startup phase is run after drivers are loaded, but before entries are displayed.
#[serde(default)]
pub startup: Vec<PhaseConfiguration>,
/// The late phase is run after the entry is chosen, but before the actions are executed.
#[serde(default)]
pub late: Vec<PhaseConfiguration>,
}
/// Configures a single phase of the boot process.
/// There can be multiple phase configurations that are
/// executed sequentially.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct PhaseConfiguration {
/// The actions to run when the phase is executed.
#[serde(default)]
pub actions: Vec<String>,
/// The values to insert into the context when the phase is executed.
#[serde(default)]
pub values: BTreeMap<String, String>,
}
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "edera-sprout-eficore"
description = "Sprout EFI Core"
license.workspace = true
version.workspace = true
homepage.workspace = true
repository.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
bitflags.workspace = true
log.workspace = true
spin.workspace = true
uefi.workspace = true
uefi-raw.workspace = true
[lib]
name = "eficore"
path = "src/lib.rs"
test = false
-390
View File
@@ -1,390 +0,0 @@
use crate::bootloader_interface::bitflags::LoaderFeatures;
use crate::platform::timer::PlatformTimer;
use crate::variables::{VariableClass, VariableController};
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;
/// bitflags: LoaderFeatures bitflags.
mod bitflags;
/// 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, and wait for the user.
MenuForce,
/// Force the menu to be shown, with a timeout.
MenuForceTimeout(u64),
/// Hide the menu.
MenuHidden,
/// Disable the menu.
MenuDisabled,
/// 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;
impl BootloaderInterface {
/// Bootloader Interface GUID from https://systemd.io/BOOT_LOADER_INTERFACE
const VENDOR: VariableController = VariableController::new(VariableVendor(guid!(
"4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
)));
/// The feature we support in Sprout.
fn features() -> LoaderFeatures {
LoaderFeatures::Xbootldr
| LoaderFeatures::Tpm2ActivePcrBanks
| LoaderFeatures::RetainShim
| LoaderFeatures::ConfigTimeout
| LoaderFeatures::ConfigTimeoutOneShot
| 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.
pub fn mark_init(timer: &PlatformTimer) -> Result<()> {
Self::mark_time("LoaderTimeInitUSec", timer)
}
/// Tell the system that Sprout is about to execute the boot entry.
pub fn mark_exec(timer: &PlatformTimer) -> Result<()> {
Self::mark_time("LoaderTimeExecUSec", timer)
}
/// Tell the system that Sprout is about to display the menu.
pub fn mark_menu(timer: &PlatformTimer) -> Result<()> {
Self::mark_time("LoaderTimeMenuUSec", timer)
}
/// Tell the system about the current time as measured by the platform timer.
/// Sets the variable specified by `key` to the number of microseconds.
fn mark_time(key: &str, timer: &PlatformTimer) -> Result<()> {
// Measure the elapsed time since the hardware timer was started.
let elapsed = timer.elapsed_since_lifetime();
Self::VENDOR.set_cstr16(
key,
&elapsed.as_micros().to_string(),
VariableClass::BootAndRuntimeTemporary,
)
}
/// Tell the system what loader is being used and our features.
pub fn set_loader_info() -> Result<()> {
// Set the LoaderInfo variable with the name of the loader.
Self::VENDOR
.set_cstr16(
"LoaderInfo",
LOADER_NAME,
VariableClass::BootAndRuntimeTemporary,
)
.context("unable to set loader info variable")?;
// Set the LoaderFeatures variable with the features we support.
Self::VENDOR
.set_u64le(
"LoaderFeatures",
Self::features().bits(),
VariableClass::BootAndRuntimeTemporary,
)
.context("unable to set loader features variable")?;
Ok(())
}
/// Tell the system the relative path to the partition root of the current bootloader.
pub fn set_loader_path(path: &DevicePath) -> Result<()> {
let subpath =
crate::path::device_path_subpath(path).context("unable to get loader path subpath")?;
Self::VENDOR.set_cstr16(
"LoaderImageIdentifier",
&subpath,
VariableClass::BootAndRuntimeTemporary,
)
}
/// Tell the system what the partition GUID of the ESP Sprout was booted from is.
pub fn set_partition_guid(guid: &Guid) -> Result<()> {
Self::VENDOR.set_cstr16(
"LoaderDevicePartUUID",
&guid.to_string(),
VariableClass::BootAndRuntimeTemporary,
)
}
/// Tell the system what boot entries are available.
pub fn set_entries<N: AsRef<str>>(entries: impl Iterator<Item = N>) -> Result<()> {
// Entries are stored as a null-terminated list of CString16 strings back to back.
// Iterate over the entries and convert them to CString16 placing them into data.
let mut data = Vec::new();
for entry in entries {
// Convert the entry to CString16 little endian.
let encoded = entry
.as_ref()
.encode_utf16()
.flat_map(|c| c.to_le_bytes())
.collect::<Vec<u8>>();
// Write the bytes into the data buffer.
data.extend_from_slice(&encoded);
// Add a null terminator to the end of the entry.
data.extend_from_slice(&[0, 0]);
}
// If no data was generated, we will do nothing.
if data.is_empty() {
return Ok(());
}
Self::VENDOR.set(
"LoaderEntries",
&data,
VariableClass::BootAndRuntimeTemporary,
)
}
/// Tell the system what the selected boot entry is.
pub fn set_selected_entry(entry: String) -> Result<()> {
Self::VENDOR.set_cstr16(
"LoaderEntrySelected",
&entry,
VariableClass::BootAndRuntimeTemporary,
)
}
/// Tell the system about the UEFI firmware we are running on.
pub fn set_firmware_info() -> Result<()> {
// Access the firmware revision.
let firmware_revision = uefi::system::firmware_revision();
// Access the UEFI revision.
let uefi_revision = uefi::system::uefi_revision();
// Format the firmware information string into something human-readable.
let firmware_info = format!(
"{} {}.{:02}",
uefi::system::firmware_vendor(),
firmware_revision >> 16,
firmware_revision & 0xffff,
);
Self::VENDOR.set_cstr16(
"LoaderFirmwareInfo",
&firmware_info,
VariableClass::BootAndRuntimeTemporary,
)?;
// Format the firmware revision into something human-readable.
let firmware_type = format!(
"UEFI {}.{:02}",
uefi_revision.major(),
uefi_revision.minor()
);
Self::VENDOR.set_cstr16(
"LoaderFirmwareType",
&firmware_type,
VariableClass::BootAndRuntimeTemporary,
)
}
/// Tell the system what the number of active PCR banks is.
/// If this is zero, that is okay.
pub fn set_tpm2_active_pcr_banks(value: u32) -> Result<()> {
// Format the value into the specification format.
let value = format!("0x{:08x}", value);
Self::VENDOR.set_cstr16(
"LoaderTpm2ActivePcrBanks",
&value,
VariableClass::BootAndRuntimeTemporary,
)
}
/// Retrieve the timeout value from the bootloader interface, using the specified `key`.
/// `remove` indicates whether, when found, we remove the variable.
fn get_timeout_value(key: &str, remove: bool) -> Result<Option<BootloaderInterfaceTimeout>> {
// Retrieve the timeout value from the bootloader interface.
let Some(value) = Self::VENDOR
.get_cstr16(key)
.context("unable to get timeout value")?
else {
return Ok(None);
};
// If we reach here, we know the value was specified.
// If `remove` is true, remove the 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.
if value.is_empty() {
return Ok(Some(BootloaderInterfaceTimeout::Unspecified));
}
// If the value is "menu-force", return MenuForce.
if value == "menu-force" {
return Ok(Some(BootloaderInterfaceTimeout::MenuForce));
}
// If the value is "menu-hidden", return MenuHidden.
if value == "menu-hidden" {
return Ok(Some(BootloaderInterfaceTimeout::MenuHidden));
}
// If the value is "menu-disabled", return MenuDisabled.
if value == "menu-disabled" {
return Ok(Some(BootloaderInterfaceTimeout::MenuDisabled));
}
// Parse the value as a u64 to decode an numeric value.
// 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 timeouts from the bootloader interface.
/// This indicates how the menu should behave.
/// A timeout that is not set is Unspecified.
pub fn get_timeouts() -> Result<BootloaderInterfaceTimeouts> {
// Attempt to acquire the value of the LoaderConfigTimeoutOneShot 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")?
.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.
// 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")
.unwrap_or_else(|error| {
warn!("{:#}", error);
None
})
.map(|direct| match direct {
BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuHidden,
other => other,
})
.unwrap_or_default();
Ok(BootloaderInterfaceTimeouts { oneshot, direct })
}
/// 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>> {
// 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")?
.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.
/// This should be the entry we boot.
pub fn get_oneshot_entry() -> Result<Option<String>> {
// Acquire the value of the LoaderEntryOneShot variable.
// If it is not set, return None.
let Some(value) = Self::VENDOR
.get_cstr16("LoaderEntryOneShot")
.context("unable to get oneshot entry from bootloader interface")?
else {
return Ok(None);
};
// Remove the oneshot entry from the bootloader interface.
// If removal fails, the value is still used, as it was meant for this boot.
if let Err(error) = Self::VENDOR.remove("LoaderEntryOneShot") {
warn!("unable to remove LoaderEntryOneShot variable: {:#}", error);
}
// Return the oneshot value.
// An empty value is treated as if the oneshot entry was not set.
Ok(Some(value).filter(|value| !value.is_empty()))
}
}
@@ -1,52 +0,0 @@
use bitflags::bitflags;
bitflags! {
/// Feature bitflags for the bootloader interface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct LoaderFeatures: u64 {
/// Bootloader supports LoaderConfigTimeout.
const ConfigTimeout = 1 << 0;
/// Bootloader supports LoaderConfigTimeoutOneShot.
const ConfigTimeoutOneShot = 1 << 1;
/// Bootloader supports LoaderEntryDefault.
const EntryDefault = 1 << 2;
/// Bootloader supports LoaderEntryOneShot.
const EntryOneShot = 1 << 3;
/// Bootloader supports boot counting.
const BootCounting = 1 << 4;
/// Bootloader supports detection from XBOOTLDR partitions.
const Xbootldr = 1 << 5;
/// Bootloader supports the handling of random seeds.
const RandomSeed = 1 << 6;
/// Bootloader supports loading drivers.
const LoadDriver = 1 << 7;
/// Bootloader supports sort keys.
const SortKey = 1 << 8;
/// Bootloader supports saved entries.
const SavedEntry = 1 << 9;
/// Bootloader supports device trees.
const DeviceTree = 1 << 10;
/// Bootloader supports secure boot enroll.
const SecureBootEnroll = 1 << 11;
/// Bootloader retains the shim.
const RetainShim = 1 << 12;
/// Bootloader supports disabling the menu via the menu timeout variable.
const MenuDisable = 1 << 13;
/// Bootloader supports multi-profile UKI.
const MultiProfileUki = 1 << 14;
/// Bootloader reports URLs.
const ReportUrl = 1 << 15;
/// Bootloader supports type-1 UKIs.
const Type1Uki = 1 << 16;
/// Bootloader supports type-1 UKI urls.
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
@@ -1,192 +0,0 @@
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);
}
}
}
-98
View File
@@ -1,98 +0,0 @@
use alloc::string::String;
use alloc::vec::Vec;
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>> {
// Acquire the current image handle.
let handle = uefi::boot::image_handle();
// Open the LoadedImage protocol for the current image.
let loaded_image = uefi::boot::open_protocol_exclusive::<LoadedImage>(handle)
.context("unable to open loaded image protocol for current image")?;
// Load the command-line argument string.
// 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.
return Ok(Vec::new());
};
// 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);
// 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.
args = args
.into_iter()
.skip_while(|arg| {
arg.chars()
.next()
// Filter out unprintable characters and backticks.
// Both of which have been observed in the wild.
.map(|c| c.is_ascii_control() || c == '`')
.unwrap_or(false)
})
.collect();
// If there is a first argument, check if it is not an option.
// If it is not, we will assume it is the path to the executable and remove it.
if let Some(arg) = args.first()
&& !arg.starts_with('-')
{
args.remove(0);
}
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
}
-149
View File
@@ -1,149 +0,0 @@
use alloc::vec;
use alloc::vec::Vec;
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.
width: usize,
/// The height of the framebuffer in pixels.
height: usize,
/// The pixels of the framebuffer.
pixels: Vec<BltPixel>,
}
impl Framebuffer {
/// Creates a new framebuffer of the specified `width` and `height`.
pub fn new(width: usize, height: usize) -> Result<Self> {
// Verify that the size is valid during multiplication.
let size = width
.checked_mul(height)
.context("framebuffer size overflow")?;
// Initialize the pixel buffer with black pixels, with the verified size.
let pixels = vec![BltPixel::new(0, 0, 0); size];
Ok(Framebuffer {
width,
height,
pixels,
})
}
/// 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.
if x >= self.width || y >= self.height {
return None;
}
// Calculate the index of the pixel safely, returning None if it overflows.
let index = y.checked_mul(self.width)?.checked_add(x)?;
// Return the pixel at the index. If the index is out of bounds, this will return None.
self.pixels.get_mut(index)
}
/// 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 {
buffer: &self.pixels,
src: BltRegion::Full,
dest: (0, 0),
dims: (self.width, self.height),
})
.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(())
}
}
-45
View File
@@ -1,45 +0,0 @@
use anyhow::{Context, Result};
use uefi::boot::{OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol, SearchType};
use uefi::proto::ProtocolPointer;
use uefi::{Guid, Handle};
use uefi_raw::Status;
/// Find a handle that provides the specified `protocol`.
pub fn find_handle(protocol: &Guid) -> Result<Option<Handle>> {
// Locate the requested protocol handle.
match uefi::boot::locate_handle_buffer(SearchType::ByProtocol(protocol)) {
// If a handle is found, the protocol is available.
Ok(handles) => Ok(if handles.is_empty() {
None
} else {
Some(handles[0])
}),
// If an error occurs, check if it is because the protocol is not available.
// If so, return false. Otherwise, return the error.
Err(error) => {
if error.status() == Status::NOT_FOUND {
Ok(None)
} else {
Err(error).context("unable to determine if the protocol is available")
}
}
}
}
/// 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,
)
}
}
-53
View File
@@ -1,53 +0,0 @@
//! 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;
/// Load and start EFI images.
pub mod loader;
/// Logging support for EFI applications.
pub mod logger;
/// Disk partitioning support infrastructure.
pub mod partition;
/// Path handling for UEFI.
pub mod path;
/// platform: Integration or support code for specific hardware platforms.
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;
/// String utilities.
pub mod strings;
/// Implements support for the bootloader interface specification.
pub mod bootloader_interface;
/// Acquire arguments from UEFI environment.
pub mod env;
/// Support code for the EFI framebuffer.
pub mod framebuffer;
/// Support code for the media loader protocol.
pub mod media_loader;
/// setup: Code that initializes the UEFI environment for Sprout.
pub mod setup;
/// Support code for EFI variables.
pub mod variables;
-143
View File
@@ -1,143 +0,0 @@
use crate::loader::source::ImageSource;
use crate::secure::SecureBoot;
use crate::shim::hook::SecurityHook;
use crate::shim::{ShimInput, ShimSupport};
use anyhow::{Context, Result, bail};
use log::warn;
use uefi::Handle;
use uefi::boot::LoadImageSource;
/// Represents EFI image sources generically.
pub mod source;
/// Handle to a loaded EFI image.
pub struct ImageHandle {
/// Handle to the loaded image.
handle: Handle,
}
impl ImageHandle {
/// Create a new image handle based on a handle from the UEFI stack.
pub fn new(handle: Handle) -> Self {
Self { handle }
}
/// Retrieve the underlying handle.
pub fn handle(&self) -> &Handle {
&self.handle
}
}
/// Request to load an image from a source, with support for additional validation features.
pub struct ImageLoadRequest<'source> {
/// Handle to the current image.
current_image: Handle,
/// Source of the image to load.
source: ImageSource<'source>,
}
impl<'source> ImageLoadRequest<'source> {
/// Create a new image load request with a current image and a source.
pub fn new(current_image: Handle, source: ImageSource<'source>) -> Self {
Self {
current_image,
source,
}
}
/// Retrieve the current image.
pub fn current_image(&self) -> &Handle {
&self.current_image
}
/// Retrieve the source of the image to load.
pub fn source(&'source self) -> &'source ImageSource<'source> {
&self.source
}
/// Convert the request into a source.
pub fn into_source(self) -> ImageSource<'source> {
self.source
}
}
/// EFI image loader.
pub struct ImageLoader;
impl ImageLoader {
/// Load an image using the image `request` which allows
pub fn load(request: ImageLoadRequest) -> Result<ImageHandle> {
// Determine whether Secure Boot is enabled.
let secure_boot =
SecureBoot::enabled().context("unable to determine if secure boot is enabled")?;
// Determine whether the shim is loaded.
let shim_loaded = ShimSupport::loaded().context("unable to determine if shim is loaded")?;
// Determine whether the shim loader is available.
let shim_loader_available = ShimSupport::loader_available()
.context("unable to determine if shim loader is available")?;
// Determines whether LoadImage in Boot Services must be patched.
// Version 16 of the shim doesn't require extra effort to load Secure Boot binaries.
// 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 shim is loaded, we will need to retain the shim protocol to allow
// loading multiple images.
if shim_loaded {
// Retain the shim protocol after loading the image.
ShimSupport::retain()?;
}
// Clone the current image handle to use for loading the image.
let current_image = *request.current_image();
// Converts the source to a shim input with an owned data buffer.
let input = ShimInput::from(request.into_source())
.into_owned_data_buffer()
.context("unable to convert input to loaded data buffer")?;
// Constructs a LoadImageSource from the input.
let source = LoadImageSource::FromBuffer {
buffer: input.buffer().context("unable to get buffer from input")?,
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");
// If the security override is required, we will uninstall the security hook.
if requires_security_hook {
let uninstall_result = crate::shim::hook::SecurityHook::uninstall();
// Ensure we don't mask load image errors if uninstalling fails.
if result.is_err()
&& let Err(uninstall_error) = &uninstall_result
{
// Warn on the error since the load image error is more important.
warn!("unable to uninstall security hook: {}", uninstall_error);
} else {
// Otherwise, ensure we handle the original uninstallation result.
uninstall_result?;
}
}
// Assert the result and grab the handle.
let handle = result?;
// Retrieve the handle from the result and make a new image handle.
Ok(ImageHandle::new(handle))
}
}
-25
View File
@@ -1,25 +0,0 @@
use crate::path::ResolvedPath;
use crate::shim::ShimInput;
/// Represents a source of an EFI image.
pub enum ImageSource<'source> {
/// The image is located at the specified path that has been resolved.
ResolvedPath(&'source ResolvedPath),
/// The image is located in a buffer.
DataBuffer {
/// Optional path to the image.
path: Option<&'source ResolvedPath>,
/// Buffer containing the image.
buffer: &'source [u8],
},
}
/// Implement conversion from `ImageSource` to `ShimInput`, which is used by the shim support code.
impl<'source> From<ImageSource<'source>> for ShimInput<'source> {
fn from(value: ImageSource<'source>) -> Self {
match value {
ImageSource::ResolvedPath(path) => ShimInput::ResolvedPath(path),
ImageSource::DataBuffer { path, buffer } => ShimInput::DataBuffer(path, buffer),
}
}
}
-94
View File
@@ -1,94 +0,0 @@
//! Based on: https://github.com/rust-osdev/uefi-rs/blob/main/uefi/src/helpers/logger.rs
use alloc::format;
use core::fmt::Write;
use core::ptr;
use core::sync::atomic::{AtomicPtr, Ordering};
use log::{Log, Record};
use uefi::proto::console::text::Output;
/// The global logger object.
static LOGGER: Logger = Logger::new();
/// Logging mechanism for Sprout.
/// Must be initialized to be used, as we use atomic pointers to store the output to write to.
pub struct Logger {
writer: AtomicPtr<Output>,
}
impl Default for Logger {
/// Creates a default logger, which is uninitialized with an output.
fn default() -> Self {
Self::new()
}
}
impl Logger {
/// Create a new logger with an output not specified.
/// This will cause the logger to not print anything until it is configured.
pub const fn new() -> Self {
Self {
writer: AtomicPtr::new(ptr::null_mut()),
}
}
/// Retrieves the pointer to the output.
/// SAFETY: This pointer might be null, it should be checked before use.
#[must_use]
fn output(&self) -> *mut Output {
self.writer.load(Ordering::Acquire)
}
/// Sets the output to write to.
///
/// # Safety
/// This function is unsafe because the output is technically leaked and unmanaged.
pub unsafe fn set_output(&self, output: *mut Output) {
self.writer.store(output, Ordering::Release);
}
}
impl Log for Logger {
/// Enable the logger always.
fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool {
true
}
/// Log the specified `record` to the output if one is set.
fn log(&self, record: &Record) {
// Acquire the output. If one is not set, we do nothing.
let Some(output) = (unsafe { self.output().as_mut() }) else {
return;
};
// Format the log message.
let message = format!("{}", record.args());
// Iterate over every line, formatting the message and writing it to the output.
for line in message.lines() {
// The format writes the log level in front of every line of text.
let _ = writeln!(output, "[{:>5}] {}", record.level(), line);
}
}
/// This log is not buffered, so flushing isn't required.
fn flush(&self) {}
}
/// Initialize the logging environment, calling panic if something goes wrong.
pub fn init() {
// Retrieve the stdout handle and set it as the output for the global logger.
uefi::system::with_stdout(|stdout| unsafe {
// SAFETY: We are using the stdout handle to create a pointer to the output.
// The handle is global and is guaranteed to be valid for the lifetime of the program.
LOGGER.set_output(stdout);
});
// Set the logger to the global logger.
if let Err(error) = log::set_logger(&LOGGER) {
panic!("unable to set logger: {}", error);
}
// Set the max level to the level specified by the log features.
log::set_max_level(log::STATIC_MAX_LEVEL);
}
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more