10 Commits

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

15
Cargo.lock generated
View File

@@ -66,13 +66,14 @@ dependencies = [
[[package]]
name = "edera-sprout-boot"
version = "0.0.25"
version = "0.0.26"
dependencies = [
"anyhow",
"edera-sprout-build",
"edera-sprout-config",
"edera-sprout-eficore",
"hex",
"jaarg",
"log",
"sha2",
"toml",
@@ -82,18 +83,18 @@ dependencies = [
[[package]]
name = "edera-sprout-build"
version = "0.0.25"
version = "0.0.26"
[[package]]
name = "edera-sprout-config"
version = "0.0.25"
version = "0.0.26"
dependencies = [
"serde",
]
[[package]]
name = "edera-sprout-eficore"
version = "0.0.25"
version = "0.0.26"
dependencies = [
"anyhow",
"bitflags",
@@ -120,6 +121,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "jaarg"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b216e5405f7e759ee0d16007f9d5c3346f9803a2e86cf01fc8df8baac43d0fa"
[[package]]
name = "libc"
version = "0.2.177"

View File

@@ -9,7 +9,7 @@ resolver = "3"
[workspace.package]
license = "Apache-2.0"
version = "0.0.25"
version = "0.0.26"
homepage = "https://sprout.edera.dev"
repository = "https://github.com/edera-dev/sprout"
edition = "2024"
@@ -29,6 +29,11 @@ version = "0.4.3"
default-features = false
features = ["alloc"]
[workspace.dependencies.jaarg]
version = "0.2.1"
default-features = false
features = ["alloc"]
[workspace.dependencies.serde]
version = "1.0.228"
default-features = false

View File

@@ -12,6 +12,7 @@ anyhow.workspace = true
edera-sprout-config.path = "../config"
edera-sprout-eficore.path = "../eficore"
hex.workspace = true
jaarg.workspace = true
sha2.workspace = true
toml.workspace = true
log.workspace = true

View File

@@ -25,7 +25,7 @@ const LINUX_CHAINLOAD_ACTION_PREFIX: &str = "linux-chainload-";
const SCAN_LOCATIONS: &[&str] = &["\\boot", "\\"];
/// Prefixes of kernel files to scan for.
const KERNEL_PREFIXES: &[&str] = &["vmlinuz"];
const KERNEL_PREFIXES: &[&str] = &["vmlinuz", "Image"];
/// Prefixes of initramfs files to match to.
const INITRAMFS_PREFIXES: &[&str] = &["initramfs", "initrd", "initrd.img"];

View File

@@ -93,7 +93,17 @@ impl BootableEntry {
}
/// Determine if this entry matches `needle` by comparing to the name or title of the entry.
/// If `needle` ends with *, we will match a partial match.
pub fn is_match(&self, needle: &str) -> bool {
// If the needle ends with '*', we will accept a partial match.
if needle.ends_with("*") {
// Strip off any '*' at the end.
let partial = needle.trim_end_matches("*");
// Check if the name or title start with the partial match.
return self.name.starts_with(partial) || self.title.starts_with(partial);
}
// Standard quality matching rules.
self.name == needle || self.title == needle
}

View File

@@ -6,7 +6,6 @@ extern crate alloc;
use crate::context::{RootContext, SproutContext};
use crate::entries::BootableEntry;
use crate::options::SproutOptions;
use crate::options::parser::OptionsRepresentable;
use crate::phases::phase;
use alloc::collections::BTreeMap;
use alloc::format;

View File

@@ -1,10 +1,13 @@
use crate::options::parser::{OptionDescription, OptionForm, OptionsRepresentable};
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use anyhow::{Context, Result, bail};
/// The Sprout options parser.
pub mod parser;
use core::ptr::null_mut;
use jaarg::alloc::ParseMapResult;
use jaarg::{
ErrorUsageWriter, ErrorUsageWriterContext, HelpWriter, HelpWriterContext, Opt, Opts,
StandardErrorUsageWriter, StandardFullHelpWriter,
};
use log::{error, info};
use uefi_raw::Status;
/// Default configuration file path.
const DEFAULT_CONFIG_PATH: &str = "\\sprout.toml";
@@ -38,65 +41,61 @@ impl Default for SproutOptions {
}
/// The options parser mechanism for Sprout.
impl OptionsRepresentable for SproutOptions {
/// Produce the [SproutOptions] structure.
type Output = Self;
impl SproutOptions {
/// Produces [SproutOptions] from the arguments provided by the UEFI core.
/// Internally we utilize the `jaarg` argument parser which has excellent no_std support.
pub fn parse() -> Result<Self> {
// All the options for the Sprout executable.
const OPTIONS: Opts<&str> = Opts::new(&[
Opt::help_flag("help", &["--help"]).help_text("Display Sprout Help"),
Opt::flag("autoconfigure", &["--autoconfigure"])
.help_text("Enable Sprout autoconfiguration"),
Opt::value("config", &["--config"], "PATH")
.help_text("Path to Sprout configuration file"),
Opt::value("boot", &["--boot"], "ENTRY").help_text("Entry to boot, bypassing the menu"),
Opt::flag("force-menu", &["--force-menu"]).help_text("Force showing the boot menu"),
Opt::value("menu-timeout", &["--menu-timeout"], "TIMEOUT")
.help_text("Boot menu timeout, in seconds"),
]);
/// All the Sprout options that are defined.
fn options() -> &'static [(&'static str, OptionDescription<'static>)] {
&[
(
"autoconfigure",
OptionDescription {
description: "Enable Sprout Autoconfiguration",
form: OptionForm::Flag,
},
),
(
"config",
OptionDescription {
description: "Path to Sprout configuration file",
form: OptionForm::Value,
},
),
(
"boot",
OptionDescription {
description: "Entry to boot, bypassing the menu",
form: OptionForm::Value,
},
),
(
"force-menu",
OptionDescription {
description: "Force showing of the boot menu",
form: OptionForm::Flag,
},
),
(
"menu-timeout",
OptionDescription {
description: "Boot menu timeout, in seconds",
form: OptionForm::Value,
},
),
(
"help",
OptionDescription {
description: "Display Sprout Help",
form: OptionForm::Help,
},
),
]
}
// Acquire the arguments as determined by the UEFI core.
let args = eficore::env::args()?;
// Parse the OPTIONS into a map using jaarg.
let parsed = match OPTIONS.parse_map(
"sprout",
args.iter(),
|program_name| {
let ctx = HelpWriterContext {
options: &OPTIONS,
program_name,
};
info!("{}", StandardFullHelpWriter::new(ctx));
},
|program_name, error| {
let ctx = ErrorUsageWriterContext {
options: &OPTIONS,
program_name,
error,
};
error!("{}", StandardErrorUsageWriter::new(ctx));
},
) {
ParseMapResult::Map(map) => map,
ParseMapResult::ExitSuccess => unsafe {
uefi::boot::exit(uefi::boot::image_handle(), Status::SUCCESS, 0, null_mut());
},
ParseMapResult::ExitFailure => unsafe {
uefi::boot::exit(uefi::boot::image_handle(), Status::ABORTED, 0, null_mut());
},
};
/// Produces [SproutOptions] from the parsed raw `options` map.
fn produce(options: BTreeMap<String, Option<String>>) -> Result<Self> {
// Use the default value of sprout options and have the raw options be parsed into it.
let mut result = Self::default();
for (key, value) in options {
match key.as_str() {
for (key, value) in parsed {
match key {
"autoconfigure" => {
// Enable autoconfiguration.
result.autoconfigure = true;
@@ -104,12 +103,12 @@ impl OptionsRepresentable for SproutOptions {
"config" => {
// The configuration file to load.
result.config = value.context("--config option requires a value")?;
result.config = value;
}
"boot" => {
// The entry to boot.
result.boot = Some(value.context("--boot option requires a value")?);
result.boot = Some(value);
}
"force-menu" => {
@@ -119,7 +118,6 @@ impl OptionsRepresentable for SproutOptions {
"menu-timeout" => {
// The timeout for the boot menu in seconds.
let value = value.context("--menu-timeout option requires a value")?;
let value = value
.parse::<u64>()
.context("menu-timeout must be a number")?;

View File

@@ -1,153 +0,0 @@
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use anyhow::{Context, Result, bail};
use core::ptr::null_mut;
use eficore::env;
use log::info;
use uefi_raw::Status;
/// The type of option. This disambiguates different behavior
/// of how options are handled.
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub enum OptionForm {
/// A flag, like --verbose.
Flag,
/// A value, in the form --abc 123 or --abc=123.
Value,
/// Help flag, like --help.
Help,
}
/// The description of an option, used in the option parser
/// to make decisions about how to progress.
#[derive(Debug, Clone)]
pub struct OptionDescription<'a> {
/// The description of the option.
pub description: &'a str,
/// The type of option to parse as.
pub form: OptionForm,
}
/// Represents a type that can be parsed from command line arguments.
/// This is a super minimal options parser mechanism just for Sprout.
pub trait OptionsRepresentable {
/// The output type that parsing will produce.
type Output;
/// The configured options for this type. This should describe all the options
/// that are valid to produce the type. The left-hand side is the name of the option,
/// and the right-hand side is the description.
fn options() -> &'static [(&'static str, OptionDescription<'static>)];
/// Produces the type by taking the `options` and processing it into the output.
fn produce(options: BTreeMap<String, Option<String>>) -> Result<Self::Output>;
/// For minimalism, we don't want a full argument parser. Instead, we use
/// a simple --xyz = xyz: None and --abc 123 = abc: Some("123") format.
/// We also support the format: --abc=123
fn parse_raw() -> Result<BTreeMap<String, Option<String>>> {
// Access the configured options for this type.
let configured: BTreeMap<_, _> = BTreeMap::from_iter(Self::options().to_vec());
// Collect all the arguments to Sprout.
// Skip the first argument, which is the path to our executable.
let args = env::args()?;
// Represent options as key-value pairs.
let mut options = BTreeMap::new();
// Iterators makes this way easier.
let mut iterator = args.into_iter().peekable();
loop {
// Consume the next option, if any.
let Some(option) = iterator.next() else {
break;
};
// If the option doesn't start with --, that is invalid.
if !option.starts_with("--") {
bail!("invalid option: {option}");
}
// Strip the -- prefix off.
let mut option = option["--".len()..].trim().to_string();
// An optional value.
let mut value = None;
// Check if the option is of the form --abc=123
if let Some((part_key, part_value)) = option.split_once('=') {
let part_key = part_key.to_string();
let part_value = part_value.to_string();
option = part_key;
value = Some(part_value);
}
// Error on empty option names.
if option.is_empty() {
bail!("invalid empty option");
}
// Find the description of the configured option, if any.
let Some(description) = configured.get(option.as_str()) else {
bail!("invalid option: --{option}");
};
// Check if the option requires a value and error if none was provided.
if description.form == OptionForm::Value && value.is_none() {
// Check for the next value.
let maybe_next = iterator.peek();
// If the next value isn't another option, set the value to the next value.
// Otherwise, it is None.
value = if let Some(next) = maybe_next
&& !next.starts_with("--")
{
iterator.next()
} else {
None
};
}
// If the option form does not support a value and there is a value, error.
if description.form != OptionForm::Value && value.is_some() {
bail!("option --{} does not take a value", option);
}
// Handle the --help flag case.
if description.form == OptionForm::Help {
// Generic configured options output.
info!("Configured Options:");
for (name, description) in &configured {
info!(
" --{}{}: {}",
name,
if description.form == OptionForm::Value {
" <value>"
} else {
""
},
description.description
);
}
// Exit because the help has been displayed.
unsafe {
uefi::boot::exit(uefi::boot::image_handle(), Status::SUCCESS, 0, null_mut());
};
}
// Insert the option and the value into the map.
options.insert(option, value);
}
Ok(options)
}
/// Parses the program arguments as a [Self::Output], calling [Self::parse_raw] and [Self::produce].
fn parse() -> Result<Self::Output> {
// Parse the program arguments into a raw map.
let options = Self::parse_raw().context("unable to parse options")?;
// Produce the options from the map.
Self::produce(options)
}
}

View File

@@ -78,8 +78,7 @@ pub struct RootConfiguration {
/// Options configuration for Sprout, used when the corresponding options are not specified.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct OptionsConfiguration {
/// The entry to boot without showing the boot menu.
/// If not specified, a boot menu is shown.
/// The entry to mark as the default entry, instead of the first entry.
#[serde(rename = "default-entry", default)]
pub default_entry: Option<String>,
/// The timeout of the boot menu.

View File

@@ -30,9 +30,9 @@ if [ "${QEMU_GDB_WAIT}" = "1" ]; then
set -- "${@}" "-S"
fi
set -- "${@}" -smp 2 -m 4096
set -- "${@}" -nodefaults -smp 2 -m 4096
if [ "${NO_GRAPHICAL_BOOT}" = "1" ]; then
if [ "${NO_GRAPHICAL}" = "1" ]; then
set -- "${@}" -nographic
else
if [ "${GRAPHICAL_ONLY}" != "1" ]; then
@@ -41,7 +41,7 @@ else
else
set -- "${@}" \
-device virtio-serial-pci,id=vs0 \
-chardev stdio,id=stdio0 \
-chardev stdio,id=stdio0,signal=off \
-device virtconsole,chardev=stdio0,id=console0
fi
fi
@@ -62,6 +62,12 @@ if [ "${NO_INPUT}" != "1" ]; then
-device usb-mouse
fi
if [ "${NO_NETWORK}" != "1" ]; then
set -- "${@}" \
-netdev user,id=network0 \
-device virtio-net-pci,netdev=network0
fi
rm -f "${FINAL_DIR}/ovmf-boot.fd"
cp "${FINAL_DIR}/ovmf.fd" "${FINAL_DIR}/ovmf-boot.fd"
if [ "${TARGET_ARCH}" = "aarch64" ]; then

View File

@@ -48,7 +48,7 @@ copy_from_image_polyfill() {
SOURCE="${2}"
TARGET="${3}"
docker build -t "${IMAGE}-copy-polyfill:${DOCKER_TAG}" --build-arg "TARGET_IMAGE=${IMAGE}:${DOCKER_TAG}" \
docker build --platform="${DOCKER_TARGET}" -t "${IMAGE}-copy-polyfill:${DOCKER_TAG}" --build-arg "TARGET_IMAGE=${IMAGE}:${DOCKER_TAG}" \
-f hack/dev/utils/Dockerfile.copy-polyfill hack
# note: the -w '//' is a workaround for Git Bash where / is magically rewritten.
docker run --rm -i -w '//' "${IMAGE}-copy-polyfill:${DOCKER_TAG}" cat "image/${SOURCE}" >"${TARGET}"
@@ -81,7 +81,7 @@ if [ "${SKIP_VM_BUILD}" != "1" ]; then
copy_from_image "${DOCKER_PREFIX}/sprout-ovmf-${TARGET_ARCH}" "ovmf.fd" "${FINAL_DIR}/ovmf.fd"
copy_from_image "${DOCKER_PREFIX}/sprout-ovmf-${TARGET_ARCH}" "shell.efi" "${FINAL_DIR}/shell.efi"
docker build --platform="${DOCKER_TARGET}" -t "${DOCKER_PREFIX}/sprout-initramfs-${TARGET_ARCH}:${DOCKER_TAG}" \
-f hack/dev/vm/Dockerfile.initramfs "${FINAL_DIR}"
-f hack/dev/vm/Dockerfile.initramfs "hack/dev/vm"
copy_from_image "${DOCKER_PREFIX}/sprout-initramfs-${TARGET_ARCH}" "initramfs" "${FINAL_DIR}/initramfs"
if [ -n "${SPROUT_XEN_EFI_OVERRIDE}" ]; then

View File

@@ -8,7 +8,7 @@ has-item = "\\vmlinuz"
[actions.chainload-kernel]
chainload.path = "$boot\\vmlinuz"
chainload.options = ["console=hvc0"]
chainload.options = ["console=hvc0", "overlaytmpfs=yes"]
chainload.linux-initrd = "$boot\\initramfs"
[entries.kernel]

View File

@@ -9,7 +9,7 @@ has-item = "\\vmlinuz"
[actions.chainload-kernel]
chainload.path = "$boot\\vmlinuz"
chainload.options = ["console=hvc0"]
chainload.options = ["console=hvc0", "overlaytmpfs=yes"]
chainload.linux-initrd = "$boot\\initramfs"
[entries.kernel]

View File

@@ -36,9 +36,14 @@ if [ "${TARGET_KARCH}" = "x86_64" ]; then
fi
make CROSS_COMPILE="${MAYBE_CROSS_COMPILE}" ARCH="${TARGET_KARCH}" mod2yesconfig
./scripts/config -e UEVENT_HELPER
./scripts/config --set-str UEVENT_HELPER_PATH "/sbin/hotplug"
./scripts/config -e DRM_VIRTIO_GPU
./scripts/config -e FRAMEBUFFER_CONSOLE
./scripts/config -e FRAMEBUFFER_CONSOLE_DETECT_PRIMARY
./scripts/config -e LOGO
./scripts/config -e XEN_DOM0
make "-j$(nproc)" CROSS_COMPILE="${MAYBE_CROSS_COMPILE}" ARCH="${TARGET_KARCH}"

View File

@@ -1,5 +1,5 @@
FROM alpine:3.22@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 AS rootfs
RUN apk --no-cache add alpine-base tzdata
RUN apk --no-cache add alpine-base tzdata ifupdown-ng agetty
RUN rc-update add devfs sysinit && \
rc-update add dmesg sysinit && \
rc-update add mdev sysinit && \
@@ -7,6 +7,7 @@ RUN rc-update add devfs sysinit && \
rc-update add sysctl boot && \
rc-update add hostname boot && \
rc-update add bootmisc boot && \
rc-update add networking boot && \
rc-update add syslog boot && \
rc-update add mount-ro shutdown && \
rc-update add killprocs shutdown && \
@@ -14,9 +15,10 @@ RUN rc-update add devfs sysinit && \
ln -s /sbin/init /init && \
echo 'root:root' | chpasswd && \
echo 'sprout' > /etc/hostname && \
echo '' > /etc/motd && \
rm /etc/motd && \
ln -s /usr/share/zoneinfo/UTC /etc/localtime && \
echo 'hvc0::respawn:/sbin/getty -L hvc0 115200 vt100' >> /etc/inittab
echo 'hvc0::respawn:/sbin/agetty --autologin root -L hvc0 115200 vt100' >> /etc/inittab
COPY files/interfaces /etc/network/interfaces
FROM alpine:3.22@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 AS build
COPY --from=rootfs / /rootfs

View File

@@ -0,0 +1,3 @@
auto eth0
iface eth0
use dhcp