deb-bootupd/src/bootupd.rs
Joe e054db031b
Some checks failed
Build deb-bootupd Artifacts / build (push) Failing after 8m52s
fix: Improve EFI component error handling for non-OSTree systems
- Fix EFI component Drop implementation to only unmount when mountpoint exists
- Prevent 'No such file or directory' errors during EFI component cleanup
- Add graceful error handling in generate_update_metadata for component failures
- Allow metadata generation to continue even when some components fail
- Provide clear warning messages for failed components
- Maintain system stability during EFI component operations
2025-08-28 12:48:01 -07:00

824 lines
28 KiB
Rust
Executable file

#[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))]
use crate::bios;
use crate::component;
use crate::component::{Component, ValidationResult};
use crate::coreos;
use crate::debian_tools;
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
use crate::efi;
use crate::freezethaw::fsfreeze_thaw_cycle;
use crate::model::{ComponentStatus, ComponentUpdatable, ContentMetadata, SavedState, Status};
use crate::{ostreeutil, util};
use anyhow::{anyhow, Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use clap::crate_version;
use fn_error_context::context;
use libc::mode_t;
use libc::{S_IRGRP, S_IROTH, S_IRUSR, S_IWUSR};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
pub(crate) enum ConfigMode {
None,
Static,
WithUUID,
}
impl ConfigMode {
pub(crate) fn enabled_with_uuid(&self) -> Option<bool> {
match self {
ConfigMode::None => None,
ConfigMode::Static => Some(false),
ConfigMode::WithUUID => Some(true),
}
}
}
pub(crate) fn install(
source_root: &str,
dest_root: &str,
device: Option<&str>,
configs: ConfigMode,
update_firmware: bool,
target_components: Option<&[String]>,
auto_components: bool,
) -> Result<()> {
// Validate input parameters
if source_root.is_empty() {
anyhow::bail!("source_root cannot be empty");
}
if dest_root.is_empty() {
anyhow::bail!("dest_root cannot be empty");
}
// TODO: Change this to an Option<&str>; though this probably balloons into having
// DeviceComponent and FileBasedComponent
let device = device.unwrap_or("");
let source_root = openat::Dir::open(source_root)
.with_context(|| format!("Opening source root: {}", source_root))?;
SavedState::ensure_not_present(dest_root)
.context("failed to install, invalid re-install attempted")?;
let all_components = get_components_impl(auto_components);
if all_components.is_empty() {
println!("No components available for this platform.");
return Ok(());
}
let target_components = if let Some(target_components) = target_components {
// Checked by CLI parser
assert!(!auto_components);
if target_components.is_empty() {
anyhow::bail!("No target components specified");
}
target_components
.iter()
.map(|name| {
if name.is_empty() {
anyhow::bail!("Component name cannot be empty");
}
all_components
.get(name.as_str())
.ok_or_else(|| anyhow!("Unknown component: '{}'", name))
})
.collect::<Result<Vec<_>>>()?
} else {
all_components.values().collect()
};
if target_components.is_empty() && !auto_components {
anyhow::bail!("No components specified");
}
log::info!(
"Installing {} components to {}",
target_components.len(),
dest_root
);
let mut state = SavedState::default();
let mut installed_efi_vendor = None;
for &component in target_components.iter() {
// skip for BIOS if device is empty
if component.name() == "BIOS" && device.is_empty() {
println!(
"Skip installing component {} without target device",
component.name()
);
continue;
}
let meta = component
.install(&source_root, dest_root, device, update_firmware)
.with_context(|| format!("installing component {}", component.name()))?;
log::info!("Installed {} {}", component.name(), meta.meta.version);
state.installed.insert(component.name().into(), meta);
// Yes this is a hack...the Component thing just turns out to be too generic.
if let Some(vendor) = component.get_efi_vendor(&source_root)? {
assert!(installed_efi_vendor.is_none());
installed_efi_vendor = Some(vendor);
}
}
let sysroot = &openat::Dir::open(dest_root)?;
match configs.enabled_with_uuid() {
Some(uuid) => {
let meta = get_static_config_meta()?;
state.static_configs = Some(meta);
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "powerpc64",
target_arch = "riscv64"
))]
crate::grubconfigs::install(sysroot, installed_efi_vendor.as_deref(), uuid)?;
// On other architectures, assume that there's nothing to do.
}
None => {}
}
// Unmount the ESP, etc.
drop(target_components);
let mut state_guard =
SavedState::unlocked(sysroot.try_clone()?).context("failed to acquire write lock")?;
state_guard
.update_state(&state)
.context("failed to update state")?;
Ok(())
}
#[context("Get static config metadata")]
fn get_static_config_meta() -> Result<ContentMetadata> {
let self_bin_meta = std::fs::metadata("/proc/self/exe").context("Querying self meta")?;
let self_meta = ContentMetadata {
timestamp: self_bin_meta.modified()?.into(),
version: crate_version!().into(),
};
Ok(self_meta)
}
type Components = BTreeMap<&'static str, Box<dyn Component>>;
#[allow(clippy::box_default)]
/// Return the set of known components; if `auto` is specified then the system
/// filters to the target booted state.
pub(crate) fn get_components_impl(auto: bool) -> Components {
let mut components = BTreeMap::new();
fn insert_component(components: &mut Components, component: Box<dyn Component>) {
components.insert(component.name(), component);
}
#[cfg(target_arch = "x86_64")]
{
if auto {
let is_efi_booted = crate::efi::is_efi_booted().unwrap();
log::info!(
"System boot method: {}",
if is_efi_booted { "EFI" } else { "BIOS" }
);
if is_efi_booted {
insert_component(&mut components, Box::new(efi::Efi::default()));
} else {
insert_component(&mut components, Box::new(bios::Bios::default()));
}
} else {
insert_component(&mut components, Box::new(bios::Bios::default()));
insert_component(&mut components, Box::new(efi::Efi::default()));
}
}
#[cfg(any(target_arch = "aarch64", target_arch = "riscv64"))]
insert_component(&mut components, Box::new(efi::Efi::default()));
#[cfg(target_arch = "powerpc64")]
insert_component(&mut components, Box::new(bios::Bios::default()));
components
}
pub(crate) fn get_components() -> Components {
get_components_impl(false)
}
pub(crate) fn generate_update_metadata(sysroot_path: &str) -> Result<()> {
// create bootupd update dir which will save component metadata files for both components
let updates_dir = Path::new(sysroot_path).join(crate::model::BOOTUPD_UPDATES_DIR);
std::fs::create_dir_all(&updates_dir)
.with_context(|| format!("Failed to create updates dir {:?}", &updates_dir))?;
let mut successful_components = 0;
let mut failed_components = Vec::new();
for component in get_components().values() {
match component.generate_update_metadata(sysroot_path) {
Ok(v) => {
println!(
"Generated update layout for {}: {}",
component.name(),
v.version,
);
successful_components += 1;
}
Err(e) => {
let error_msg = format!("{}: {}", component.name(), e);
println!("Warning: Failed to generate metadata for {}: {}", component.name(), e);
failed_components.push(error_msg);
}
}
}
// If no components succeeded, return an error
if successful_components == 0 {
anyhow::bail!("Failed to generate metadata for any components: {}", failed_components.join("; "));
}
// If some components failed, log a warning but continue
if !failed_components.is_empty() {
println!("Note: {} component(s) failed, but {} component(s) succeeded",
failed_components.len(), successful_components);
}
Ok(())
}
/// Return value from daemon → client for component update
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ComponentUpdateResult {
AtLatestVersion,
Updated {
previous: ContentMetadata,
interrupted: Option<ContentMetadata>,
new: ContentMetadata,
},
}
fn ensure_writable_boot() -> Result<()> {
util::ensure_writable_mount("/boot")
}
/// daemon implementation of component update
pub(crate) fn update(name: &str, rootcxt: &RootContext) -> Result<ComponentUpdateResult> {
let mut state = SavedState::load_from_disk("/")?.unwrap_or_default();
let component = component::new_from_name(name)?;
let inst = if let Some(inst) = state.installed.get(name) {
inst.clone()
} else {
anyhow::bail!("Component {} is not installed", name);
};
let sysroot = &rootcxt.sysroot;
let update = component.query_update(sysroot)?;
let update = match update.as_ref() {
Some(p) if inst.meta.can_upgrade_to(p) => p,
_ => return Ok(ComponentUpdateResult::AtLatestVersion),
};
ensure_writable_boot()?;
let mut pending_container = state.pending.take().unwrap_or_default();
let interrupted = pending_container.get(component.name()).cloned();
pending_container.insert(component.name().into(), update.clone());
let sysroot = sysroot.try_clone()?;
let mut state_guard =
SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?;
state_guard
.update_state(&state)
.context("Failed to update state")?;
let newinst = component
.run_update(rootcxt, &inst)
.with_context(|| format!("Failed to update {}", component.name()))?;
state.installed.insert(component.name().into(), newinst);
pending_container.remove(component.name());
state_guard.update_state(&state)?;
Ok(ComponentUpdateResult::Updated {
previous: inst.meta,
interrupted,
new: update.clone(),
})
}
/// daemon implementation of component adoption
pub(crate) fn adopt_and_update(
name: &str,
rootcxt: &RootContext,
with_static_config: bool,
) -> Result<Option<ContentMetadata>> {
let sysroot = &rootcxt.sysroot;
let mut state = SavedState::load_from_disk("/")?.unwrap_or_default();
let component = component::new_from_name(name)?;
if state.installed.contains_key(name) {
anyhow::bail!("Component {} is already installed", name);
};
ensure_writable_boot()?;
let Some(update) = component.query_update(sysroot)? else {
anyhow::bail!("Component {} has no available update", name);
};
let sysroot = sysroot.try_clone()?;
let mut state_guard =
SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?;
let inst = component
.adopt_update(&rootcxt, &update, with_static_config)
.context("Failed adopt and update")?;
if let Some(inst) = inst {
state.installed.insert(component.name().into(), inst);
// Set static_configs metadata and save
if with_static_config && state.static_configs.is_none() {
let meta = get_static_config_meta()?;
state.static_configs = Some(meta);
// Set bootloader to none
ostreeutil::set_ostree_bootloader("none")?;
println!("Static GRUB configuration has been adopted successfully.");
}
state_guard.update_state(&state)?;
return Ok(Some(update));
} else {
// Nothing adopted, skip
log::info!("Component '{}' skipped adoption", component.name());
return Ok(None);
}
}
/// daemon implementation of component validate
pub(crate) fn validate(name: &str) -> Result<ValidationResult> {
let state = SavedState::load_from_disk("/")?.unwrap_or_default();
let component = component::new_from_name(name)?;
let Some(inst) = state.installed.get(name) else {
anyhow::bail!("Component {} is not installed", name);
};
component.validate(inst)
}
pub(crate) fn status() -> Result<Status> {
let mut ret: Status = Default::default();
let mut known_components = get_components();
let sysroot = openat::Dir::open("/")?;
let state = SavedState::load_from_disk("/")?;
if let Some(state) = state {
for (name, ic) in state.installed.iter() {
log::trace!("Gathering status for installed component: {}", name);
let component = known_components
.remove(name.as_str())
.ok_or_else(|| anyhow!("Unknown component installed: {}", name))?;
let component = component.as_ref();
let interrupted = state.pending.as_ref().and_then(|p| p.get(name.as_str()));
let update = component.query_update(&sysroot)?;
let updatable = ComponentUpdatable::from_metadata(&ic.meta, update.as_ref());
let adopted_from = ic.adopted_from.clone();
ret.components.insert(
name.to_string(),
ComponentStatus {
installed: ic.meta.clone(),
interrupted: interrupted.cloned(),
update,
updatable,
adopted_from,
},
);
}
} else {
log::trace!("No saved state");
}
// Process the remaining components not installed
log::trace!("Remaining known components: {}", known_components.len());
for (name, _) in known_components {
// To determine if not-installed components can be adopted:
//
// `query_adopt_state()` checks for existing installation state,
// such as a `version` in `/sysroot/.coreos-aleph-version.json`,
// or the presence of `/ostree/deploy`.
//
// `component.query_adopt()` performs additional checks,
// including hardware/device requirements.
// For example, it will skip BIOS adoption if the system is booted via EFI
// and lacks a BIOS_BOOT partition.
//
// Once a component is determined to be adoptable, it is added to the
// adoptable list, and adoption proceeds automatically.
//
// Therefore, calling `query_adopt_state()` alone is sufficient.
if let Some(adopt_ver) = crate::component::query_adopt_state()? {
ret.adoptable.insert(name.to_string(), adopt_ver);
} else {
log::trace!("Not adoptable: {}", name);
}
}
Ok(ret)
}
pub(crate) fn print_status_avail(status: &Status) -> Result<()> {
let mut avail = Vec::new();
for (name, component) in status.components.iter() {
if let ComponentUpdatable::Upgradable = component.updatable {
avail.push(name.as_str());
}
}
for (name, adoptable) in status.adoptable.iter() {
if adoptable.confident {
avail.push(name.as_str());
}
}
if !avail.is_empty() {
println!("Updates available: {}", avail.join(" "));
}
Ok(())
}
pub(crate) fn print_status(status: &Status) -> Result<()> {
if status.components.is_empty() {
println!("No components installed.");
}
for (name, component) in status.components.iter() {
println!("Component {}", name);
println!(" Installed: {}", component.installed.version);
if let Some(i) = component.interrupted.as_ref() {
println!(
" WARNING: Previous update to {} was interrupted",
i.version
);
}
let msg = match component.updatable {
ComponentUpdatable::NoUpdateAvailable => Cow::Borrowed("No update found"),
ComponentUpdatable::AtLatestVersion => Cow::Borrowed("At latest version"),
ComponentUpdatable::WouldDowngrade => Cow::Borrowed("Ignoring downgrade"),
ComponentUpdatable::Upgradable => Cow::Owned(format!(
"Available: {}",
component.update.as_ref().expect("update").version
)),
};
println!(" Update: {}", msg);
}
if status.adoptable.is_empty() {
println!("No components are adoptable.");
}
for (name, adopt) in status.adoptable.iter() {
let ver = &adopt.version.version;
if adopt.confident {
println!("Detected: {}: {}", name, ver);
} else {
println!("Adoptable: {}: {}", name, ver);
}
}
if let Some(coreos_aleph) = coreos::get_aleph_version(Path::new("/"))? {
println!(
"CoreOS aleph version: {}",
coreos_aleph.version_info.version
);
}
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
{
let boot_method = if efi::is_efi_booted()? { "EFI" } else { "BIOS" };
println!("Boot method: {}", boot_method);
}
Ok(())
}
pub struct RootContext {
pub sysroot: openat::Dir,
pub path: Utf8PathBuf,
pub devices: Vec<String>,
}
impl RootContext {
fn new(sysroot: openat::Dir, path: &str, devices: Vec<String>) -> Self {
Self {
sysroot,
path: Utf8Path::new(path).into(),
devices,
}
}
}
/// Initialize parent devices to prepare the update
fn prep_before_update() -> Result<RootContext> {
let path = "/";
let sysroot = openat::Dir::open(path).context("Opening root dir")?;
let devices = crate::blockdev::get_devices(path).context("get parent devices")?;
Ok(RootContext::new(sysroot, path, devices))
}
pub(crate) fn client_run_update() -> Result<()> {
crate::try_fail_point!("update");
let rootcxt = prep_before_update()?;
let status: Status = status()?;
if status.components.is_empty() && status.adoptable.is_empty() {
println!("No components installed.");
return Ok(());
}
let mut updated = false;
for (name, cstatus) in status.components.iter() {
match cstatus.updatable {
ComponentUpdatable::Upgradable => {}
_ => continue,
};
match update(name, &rootcxt)? {
ComponentUpdateResult::AtLatestVersion => {
// Shouldn't happen unless we raced with another client
eprintln!(
"warning: Expected update for {}, raced with a different client?",
name
);
continue;
}
ComponentUpdateResult::Updated {
previous,
interrupted,
new,
} => {
if let Some(i) = interrupted {
eprintln!(
"warning: Continued from previous interrupted update: {}",
i.version,
);
}
println!("Previous {}: {}", name, previous.version);
println!("Updated {}: {}", name, new.version);
}
}
updated = true;
}
for (name, adoptable) in status.adoptable.iter() {
if adoptable.confident {
if let Some(r) = adopt_and_update(name, &rootcxt, false)? {
println!("Adopted and updated: {}: {}", name, r.version);
updated = true;
}
} else {
println!("Component {} requires explicit adopt-and-update", name);
}
}
if !updated {
println!("No update available for any component.");
}
Ok(())
}
pub(crate) fn client_run_adopt_and_update(with_static_config: bool) -> Result<()> {
let rootcxt = prep_before_update()?;
let status: Status = status()?;
if status.adoptable.is_empty() {
println!("No components are adoptable.");
} else {
for (name, _) in status.adoptable.iter() {
if let Some(r) = adopt_and_update(name, &rootcxt, with_static_config)? {
println!("Adopted and updated: {}: {}", name, r.version);
}
}
}
Ok(())
}
pub(crate) fn client_run_validate() -> Result<()> {
let status: Status = status()?;
if status.components.is_empty() {
println!("No components installed.");
return Ok(());
}
let mut caught_validation_error = false;
for (name, _) in status.components.iter() {
match validate(name)? {
ValidationResult::Valid => {
println!("Validated: {}", name);
}
ValidationResult::Skip => {
println!("Skipped: {}", name);
}
ValidationResult::Errors(errs) => {
for err in errs {
eprintln!("{}", err);
}
caught_validation_error = true;
}
}
}
if caught_validation_error {
anyhow::bail!("Caught validation errors");
}
Ok(())
}
#[context("Migrating to a static GRUB config")]
pub(crate) fn client_run_migrate_static_grub_config() -> Result<()> {
// Did we already complete the migration?
// We need to migrate if bootloader is not none (or not set)
if let Some(bootloader) = ostreeutil::get_ostree_bootloader()? {
if bootloader == "none" {
println!("Already using a static GRUB config");
return Ok(());
}
println!(
"ostree repo 'sysroot.bootloader' config option is currently set to: '{}'",
bootloader
);
} else {
println!("ostree repo 'sysroot.bootloader' config option is not set yet");
}
// Remount /boot read write just for this unit (we are called in a slave mount namespace by systemd)
ensure_writable_boot()?;
let grub_config_dir = PathBuf::from(debian_tools::get_grub_config_dir());
let dirfd = openat::Dir::open(&grub_config_dir).context(format!("Opening {}", debian_tools::get_grub_config_dir()))?;
// We mark the bootloader as BLS capable to disable the ostree-grub2 logic.
// We can do that as we know that we are run after the bootloader has been
// updated and all recent GRUB2 versions support reading BLS configs.
// Ignore errors as this is not critical. This is a safety net if a user
// manually overwrites the (soon) static GRUB config by calling `grub2-mkconfig`.
// We need this until we can rely on ostree-grub2 being removed from the image.
println!("Marking bootloader as BLS capable...");
_ = File::create(format!("{}/.grub2-blscfg-supported", debian_tools::get_grub_config_dir()));
// Migrate /boot/grub/grub.cfg or /boot/grub2/grub.cfg to a static GRUB config if it is a symlink
let grub_config_filename = PathBuf::from(format!("{}/grub.cfg", debian_tools::get_grub_config_dir()));
match dirfd.read_link("grub.cfg") {
Err(_) => {
println!(
"'{}' is not a symlink, nothing to migrate",
grub_config_filename.display()
);
}
Ok(path) => {
println!("Migrating to a static GRUB config...");
// Resolve symlink location
let mut current_config = grub_config_dir.clone();
current_config.push(path);
// Backup the current GRUB config which is hopefully working right now
let backup_config = PathBuf::from(format!("{}/grub.cfg.backup", debian_tools::get_grub_config_dir()));
println!(
"Creating a backup of the current GRUB config '{}' in '{}'...",
current_config.display(),
backup_config.display()
);
fs::copy(&current_config, &backup_config).context("Failed to backup GRUB config")?;
// Read the current config, strip the ostree generated GRUB entries and
// write the result to a temporary file
println!("Stripping ostree generated entries from GRUB config...");
let stripped_config = "grub.cfg.stripped";
let current_config_file =
File::open(current_config).context("Could not open current GRUB config")?;
let content = BufReader::new(current_config_file);
strip_grub_config_file(content, &dirfd, stripped_config)?;
// Atomically replace the symlink
dirfd
.local_rename(stripped_config, "grub.cfg")
.context("Failed to replace symlink with current GRUB config")?;
fsfreeze_thaw_cycle(dirfd.open_file(".")?)?;
println!("GRUB config symlink successfully replaced with the current config");
}
};
println!("Setting 'sysroot.bootloader' to 'none' in ostree repo config...");
ostreeutil::set_ostree_bootloader("none")?;
println!("Static GRUB config migration completed successfully");
Ok(())
}
/// Writes a stripped GRUB config to `stripped_config_name`, removing lines between
/// `### BEGIN /etc/grub.d/15_ostree ###` and `### END /etc/grub.d/15_ostree ###`.
fn strip_grub_config_file(
current_config_content: impl BufRead,
dirfd: &openat::Dir,
stripped_config_name: &str,
) -> Result<()> {
// mode = -rw-r--r-- (644)
let mut writer = BufWriter::new(
dirfd
.write_file(
stripped_config_name,
(S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) as mode_t,
)
.context("Failed to open temporary GRUB config")?,
);
let mut skip = false;
for line in current_config_content.lines() {
let line = line.context("Failed to read line from GRUB config")?;
if line == "### END /etc/grub.d/15_ostree ###" {
skip = false;
continue;
}
if skip {
continue;
}
if line == "### BEGIN /etc/grub.d/15_ostree ###" {
skip = true;
continue;
}
writer
.write_all(line.as_bytes())
.context("Failed to write stripped GRUB config")?;
writer
.write_all(b"\n")
.context("Failed to write stripped GRUB config")?;
}
writer
.into_inner()
.context("Failed to flush stripped GRUB config")?
.sync_data()
.context("Failed to sync stripped GRUB config")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_failpoint_update() {
let guard = fail::FailScenario::setup();
fail::cfg("update", "return").unwrap();
let r = client_run_update();
assert_eq!(r.is_err(), true);
guard.teardown();
}
#[test]
fn test_strip_grub_config_file() -> Result<()> {
let root: &tempfile::TempDir = &tempfile::tempdir()?;
let root_path = root.path();
let rootd = openat::Dir::open(root_path)?;
let stripped_config = root_path.join("stripped");
let content = r"
### BEGIN /etc/grub.d/10_linux ###
### END /etc/grub.d/10_linux ###
### BEGIN /etc/grub.d/15_ostree ###
menuentry 'Red Hat Enterprise Linux CoreOS 4 (ostree)' --class gnu-linux --class gnu --class os --unrestricted 'ostree-0-a92522f9-74dc-456a-ae0c-05ba22bca976' {
load_video
set gfxpayload=keep
insmod gzio
insmod part_gpt
insmod ext2
if [ x$feature_platform_search_hint = xy ]; then
search --no-floppy --fs-uuid --set=root a92522f9-74dc-456a-ae0c-05ba22bca976
else
search --no-floppy --fs-uuid --set=root a92522f9-74dc-456a-ae0c-05ba22bca976
fi
linuxefi /ostree/rhcos-bf3b382/vmlinuz console=tty0 console=ttyS0,115200n8 rootflags=defaults,prjquota rw $ignition_firstboot root=UUID=cbac8cdc
initrdefi /ostree/rhcos-bf3b382/initramfs.img
}
### END /etc/grub.d/15_ostree ###
### BEGIN /etc/grub.d/20_linux_xen ###
### END /etc/grub.d/20_linux_xen ###";
strip_grub_config_file(
BufReader::new(std::io::Cursor::new(content)),
&rootd,
stripped_config.to_str().unwrap(),
)?;
let stripped_content = fs::read_to_string(stripped_config)?;
let expected = r"
### BEGIN /etc/grub.d/10_linux ###
### END /etc/grub.d/10_linux ###
### BEGIN /etc/grub.d/20_linux_xen ###
### END /etc/grub.d/20_linux_xen ###
";
assert_eq!(expected, stripped_content);
Ok(())
}
}