tool: make kernels and initrds content-addressed

Kernels and initrds on the ESP are now content-addressed. By definition,
it is impossible for two different kernels or initrds to ever end up at
the same place, even in the presence of changing initrd secrets or other
unreproducibility.

The basic advantage of this is that installing the kernel or initrd for
a generation can never break another generation. In turn, this enables
the following two improvements:
* All generations can be installed independently. In particular, the
  installation can be performed in one pass, one generation at a time.
  As a result, the code is significantly simplified, and memory usage
  (due to the temporary files) does not grow with the number of
  generations any more.
* Generations that already have their files in place on the ESP do not
  need to be reinstalled. This will be taken advantage of in a
  subsequent commit.
This commit is contained in:
Alois Wohlschlager 2023-08-12 15:23:21 +02:00
parent bccf7738d5
commit 240914d763
No known key found for this signature in database
GPG Key ID: E0F59EA5E5216914
9 changed files with 191 additions and 419 deletions

View File

@ -133,30 +133,13 @@ let
# `src` is copied to `dst` inside th VM. Optionally append some random data # `src` is copied to `dst` inside th VM. Optionally append some random data
# ("crap") to the end of the file at `dst`. This is useful to easily change # ("crap") to the end of the file at `dst`. This is useful to easily change
# the hash of a file and produce a hash mismatch when booting the stub. # the hash of a file and produce a hash mismatch when booting the stub.
mkHashMismatchTest = { name, path, appendCrap ? false, useSecureBoot ? true }: mkSecureBootTest { mkHashMismatchTest = { name, appendCrapGlob, useSecureBoot ? true }: mkSecureBootTest {
inherit name; inherit name;
inherit useSecureBoot; inherit useSecureBoot;
testScript = '' testScript = ''
import json
import os.path
bootspec = None
def convert_to_esp(store_file_path):
store_dir = os.path.basename(os.path.dirname(store_file_path))
filename = os.path.basename(store_file_path)
return f'/boot/EFI/nixos/{store_dir}-{filename}.efi'
machine.start() machine.start()
bootspec = json.loads(machine.succeed("cat /run/current-system/boot.json")).get('org.nixos.bootspec.v1') machine.succeed("echo some_garbage_to_change_the_hash | tee -a ${appendCrapGlob} > /dev/null")
assert bootspec is not None, "Unsupported bootspec version!"
src_path = ${path.src}
dst_path = ${path.dst}
machine.succeed(f"cp -rf {src_path} {dst_path}")
'' + lib.optionalString appendCrap ''
machine.succeed(f"echo Foo >> {dst_path}")
'' +
''
machine.succeed("sync") machine.succeed("sync")
machine.crash() machine.crash()
machine.start() machine.start()
@ -174,24 +157,12 @@ let
# that would make the kernel still accept it. # that would make the kernel still accept it.
mkModifiedInitrdTest = { name, useSecureBoot }: mkHashMismatchTest { mkModifiedInitrdTest = { name, useSecureBoot }: mkHashMismatchTest {
inherit name useSecureBoot; inherit name useSecureBoot;
appendCrapGlob = "/boot/EFI/nixos/initrd-*.efi";
path = {
src = "bootspec.get('initrd')";
dst = "convert_to_esp(bootspec.get('initrd'))";
};
appendCrap = true;
}; };
mkModifiedKernelTest = { name, useSecureBoot }: mkHashMismatchTest { mkModifiedKernelTest = { name, useSecureBoot }: mkHashMismatchTest {
inherit name useSecureBoot; inherit name useSecureBoot;
appendCrapGlob = "/boot/EFI/nixos/kernel-*.efi";
path = {
src = "bootspec.get('kernel')";
dst = "convert_to_esp(bootspec.get('kernel'))";
};
appendCrap = true;
}; };
in in
@ -248,8 +219,9 @@ in
# path) does not change. # path) does not change.
# #
# An unfortunate result of this NixOS feature is that updating the secrets # An unfortunate result of this NixOS feature is that updating the secrets
# without creating a new initrd might break previous generations. Lanzaboote # without creating a new initrd might break previous generations. Verify that
# has no control over that. # a new initrd (which is supposed to only differ by the secrets) is created
# in this case.
# #
# This tests uses a specialisation to imitate a newer generation. This works # This tests uses a specialisation to imitate a newer generation. This works
# because `lzbt` installs the specialisation of a generation AFTER installing # because `lzbt` installs the specialisation of a generation AFTER installing
@ -279,12 +251,19 @@ in
machine.start() machine.start()
machine.wait_for_unit("multi-user.target") machine.wait_for_unit("multi-user.target")
# Assert that only two boot files exists (a single kernel and a single # Assert that only three boot files exists (a single kernel and a two
# initrd). If there are two initrds, the test would not be able to test # initrds).
# updating the secret of an already existing initrd. assert int(machine.succeed("ls -1 /boot/EFI/nixos | wc -l")) == 3
assert int(machine.succeed("ls -1 /boot/EFI/nixos | wc -l")) == 2
# It is expected that the initrd contains the new secret. # It is expected that the initrd contains the original secret.
machine.succeed("cmp ${originalSecret} /secret-from-initramfs")
machine.succeed("bootctl set-default nixos-generation-1-specialisation-variant.efi")
machine.succeed("sync")
machine.crash()
machine.start()
machine.wait_for_unit("multi-user.target")
# It is expected that the initrd of the specialisation contains the new secret.
machine.succeed("cmp ${newSecret} /secret-from-initramfs") machine.succeed("cmp ${newSecret} /secret-from-initramfs")
''; '';
}; };

7
rust/tool/Cargo.lock generated
View File

@ -103,6 +103,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "base32ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396664016f30ad5ab761000391a5c0b436f7bfac738858263eb25897658b98c9"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -501,6 +507,7 @@ version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",
"base32ct",
"clap", "clap",
"expect-test", "expect-test",
"filetime", "filetime",

View File

@ -1,13 +1,6 @@
use std::{ use std::path::{Path, PathBuf};
array::IntoIter,
path::{Path, PathBuf},
};
use anyhow::{bail, Context, Result};
use indoc::indoc;
use crate::architecture::Architecture; use crate::architecture::Architecture;
use crate::generation::Generation;
/// Generic ESP paths which can be specific to a bootloader /// Generic ESP paths which can be specific to a bootloader
pub trait EspPaths<const N: usize> { pub trait EspPaths<const N: usize> {
@ -23,94 +16,3 @@ pub trait EspPaths<const N: usize> {
/// Returns the path containing Linux EFI binaries /// Returns the path containing Linux EFI binaries
fn linux_path(&self) -> &Path; fn linux_path(&self) -> &Path;
} }
/// Paths to the boot files of a specific generation.
pub struct EspGenerationPaths {
pub kernel: PathBuf,
pub initrd: PathBuf,
pub lanzaboote_image: PathBuf,
}
impl EspGenerationPaths {
pub fn new<const N: usize, P: EspPaths<N>>(
esp_paths: &P,
generation: &Generation,
system: Architecture,
) -> Result<Self> {
let bootspec = &generation.spec.bootspec.bootspec;
let bootspec_system: Architecture = Architecture::from_nixos_system(&bootspec.system)?;
if system != bootspec_system {
bail!(indoc! {r#"
The CPU architecture declared in your module differs from the one declared in the
bootspec of the current generation.
"#})
}
Ok(Self {
kernel: esp_paths
.nixos_path()
.join(nixos_path(&bootspec.kernel, "bzImage")?),
initrd: esp_paths.nixos_path().join(nixos_path(
bootspec
.initrd
.as_ref()
.context("Lanzaboote does not support missing initrd yet")?,
"initrd",
)?),
lanzaboote_image: esp_paths.linux_path().join(generation_path(generation)),
})
}
/// Return the used file paths to store as garbage collection roots.
pub fn to_iter(&self) -> IntoIter<&PathBuf, 3> {
[&self.kernel, &self.initrd, &self.lanzaboote_image].into_iter()
}
}
fn nixos_path(path: impl AsRef<Path>, name: &str) -> Result<PathBuf> {
let resolved = path
.as_ref()
.read_link()
.unwrap_or_else(|_| path.as_ref().into());
let parent_final_component = resolved
.parent()
.and_then(|x| x.file_name())
.and_then(|x| x.to_str())
.with_context(|| format!("Failed to extract final component from: {:?}", resolved))?;
let nixos_filename = format!("{}-{}.efi", parent_final_component, name);
Ok(PathBuf::from(nixos_filename))
}
fn generation_path(generation: &Generation) -> PathBuf {
if let Some(specialisation_name) = generation.is_specialised() {
PathBuf::from(format!(
"nixos-generation-{}-specialisation-{}.efi",
generation, specialisation_name
))
} else {
PathBuf::from(format!("nixos-generation-{}.efi", generation))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nixos_path_creates_correct_filename_from_nix_store_path() -> Result<()> {
let path =
Path::new("/nix/store/xqplddjjjy1lhzyzbcv4dza11ccpcfds-initrd-linux-6.1.1/initrd");
let generated_filename = nixos_path(path, "initrd")?;
let expected_filename =
PathBuf::from("xqplddjjjy1lhzyzbcv4dza11ccpcfds-initrd-linux-6.1.1-initrd.efi");
assert_eq!(generated_filename, expected_filename);
Ok(())
}
}

View File

@ -8,7 +8,6 @@ use anyhow::{Context, Result};
use goblin::pe::PE; use goblin::pe::PE;
use tempfile::TempDir; use tempfile::TempDir;
use crate::esp::EspGenerationPaths;
use crate::utils::{file_hash, tmpname, SecureTempDirExt}; use crate::utils::{file_hash, tmpname, SecureTempDirExt};
/// Assemble a lanzaboote image. /// Assemble a lanzaboote image.
@ -20,9 +19,10 @@ pub fn lanzaboote_image(
lanzaboote_stub: &Path, lanzaboote_stub: &Path,
os_release: &Path, os_release: &Path,
kernel_cmdline: &[String], kernel_cmdline: &[String],
kernel_path: &Path, kernel_source: &Path,
initrd_path: &Path, kernel_target: &Path,
esp_gen_paths: &EspGenerationPaths, initrd_source: &Path,
initrd_target: &Path,
esp: &Path, esp: &Path,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
// objcopy can only copy files into the PE binary. That's why we // objcopy can only copy files into the PE binary. That's why we
@ -30,12 +30,12 @@ pub fn lanzaboote_image(
let kernel_cmdline_file = tempdir.write_secure_file(kernel_cmdline.join(" "))?; let kernel_cmdline_file = tempdir.write_secure_file(kernel_cmdline.join(" "))?;
let kernel_path_file = let kernel_path_file =
tempdir.write_secure_file(esp_relative_uefi_path(esp, &esp_gen_paths.kernel)?)?; tempdir.write_secure_file(esp_relative_uefi_path(esp, kernel_target)?)?;
let kernel_hash_file = tempdir.write_secure_file(file_hash(kernel_path)?.as_slice())?; let kernel_hash_file = tempdir.write_secure_file(file_hash(kernel_source)?.as_slice())?;
let initrd_path_file = let initrd_path_file =
tempdir.write_secure_file(esp_relative_uefi_path(esp, &esp_gen_paths.initrd)?)?; tempdir.write_secure_file(esp_relative_uefi_path(esp, initrd_target)?)?;
let initrd_hash_file = tempdir.write_secure_file(file_hash(initrd_path)?.as_slice())?; let initrd_hash_file = tempdir.write_secure_file(file_hash(initrd_source)?.as_slice())?;
let os_release_offs = stub_offset(lanzaboote_stub)?; let os_release_offs = stub_offset(lanzaboote_stub)?;
let kernel_cmdline_offs = os_release_offs + file_size(os_release)?; let kernel_cmdline_offs = os_release_offs + file_size(os_release)?;

View File

@ -7,6 +7,7 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.71" anyhow = "1.0.71"
base32ct = { version = "0.2.0", features = ["alloc"] }
stderrlog = "0.5.4" stderrlog = "0.5.4"
log = { version = "0.4.18", features = ["std"] } log = { version = "0.4.18", features = ["std"] }
clap = { version = "4.3.1", features = ["derive"] } clap = { version = "4.3.1", features = ["derive"] }

View File

@ -1,4 +1,5 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fs::{self, File}; use std::fs::{self, File};
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use std::os::unix::prelude::PermissionsExt; use std::os::unix::prelude::PermissionsExt;
@ -7,6 +8,7 @@ use std::process::Command;
use std::string::ToString; use std::string::ToString;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use base32ct::{Base32Unpadded, Encoding};
use nix::unistd::syncfs; use nix::unistd::syncfs;
use tempfile::TempDir; use tempfile::TempDir;
@ -14,7 +16,7 @@ use crate::architecture::SystemdArchitectureExt;
use crate::esp::SystemdEspPaths; use crate::esp::SystemdEspPaths;
use crate::version::SystemdVersion; use crate::version::SystemdVersion;
use lanzaboote_tool::architecture::Architecture; use lanzaboote_tool::architecture::Architecture;
use lanzaboote_tool::esp::{EspGenerationPaths, EspPaths}; use lanzaboote_tool::esp::EspPaths;
use lanzaboote_tool::gc::Roots; use lanzaboote_tool::gc::Roots;
use lanzaboote_tool::generation::{Generation, GenerationLink}; use lanzaboote_tool::generation::{Generation, GenerationLink};
use lanzaboote_tool::os_release::OsRelease; use lanzaboote_tool::os_release::OsRelease;
@ -74,9 +76,7 @@ impl Installer {
.map(GenerationLink::from_path) .map(GenerationLink::from_path)
.collect::<Result<Vec<GenerationLink>>>()?; .collect::<Result<Vec<GenerationLink>>>()?;
// Sort the links by version. The links need to always be sorted to ensure the secrets of // Sort the links by version, so that the limit actually skips the oldest generations.
// the latest generation are appended to the initrd when multiple generations point to the
// same initrd.
links.sort_by_key(|l| l.version); links.sort_by_key(|l| l.version);
// A configuration limit of 0 means there is no limit. // A configuration limit of 0 means there is no limit.
@ -129,64 +129,7 @@ impl Installer {
} }
/// Install all generations from the provided `GenerationLinks`. /// Install all generations from the provided `GenerationLinks`.
///
/// Iterates over the links twice:
/// (1) First, building all unsigned artifacts and storing the mapping from source to
/// destination in `GenerationArtifacts`. `GenerationArtifacts` ensures that there are no
/// duplicate destination paths and thus ensures that the hashes embedded in the lanzaboote
/// image do not get invalidated because the files to which they point get overwritten by a
/// later generation.
/// (2) Second, building all signed artifacts using the previously built mapping from source to
/// destination in the `GenerationArtifacts`.
///
/// This way, in the second step, all paths and thus all hashes for all generations are already
/// known. The signed files can now be constructed with known good hashes **across** all
/// generations.
fn install_generations_from_links(&mut self, links: &[GenerationLink]) -> Result<()> { fn install_generations_from_links(&mut self, links: &[GenerationLink]) -> Result<()> {
// This struct must live for the entire lifetime of this function so that the contained
// tempdir does not go out of scope and thus does not get deleted.
let mut generation_artifacts =
GenerationArtifacts::new().context("Failed to create GenerationArtifacts.")?;
self.build_generation_artifacts_from_links(
&mut generation_artifacts,
links,
Self::build_unsigned_generation_artifacts,
)
.context("Failed to build unsigned generation artifacts.")?;
self.build_generation_artifacts_from_links(
&mut generation_artifacts,
links,
Self::build_signed_generation_artifacts,
)
.context("Failed to build signed generation artifacts.")?;
generation_artifacts
.install(&self.key_pair)
.context("Failed to install files.")?;
// Sync files to persistent storage. This may improve the
// chance of a consistent boot directory in case the system
// crashes.
let boot = File::open(&self.esp_paths.esp).context("Failed to open ESP root directory.")?;
syncfs(boot.as_raw_fd()).context("Failed to sync ESP filesystem.")?;
Ok(())
}
/// Build all generation artifacts from a list of `GenerationLink`s.
///
/// This function accepts a closure to build the generation artifacts for a single generation.
fn build_generation_artifacts_from_links<F>(
&mut self,
generation_artifacts: &mut GenerationArtifacts,
links: &[GenerationLink],
mut build_generation_artifacts: F,
) -> Result<()>
where
F: FnMut(&mut Self, &Generation, &mut GenerationArtifacts) -> Result<()>,
{
let generations = links let generations = links
.iter() .iter()
.filter_map(|link| { .filter_map(|link| {
@ -214,122 +157,124 @@ impl Installer {
} }
for generation in generations { for generation in generations {
build_generation_artifacts(self, &generation, generation_artifacts) // The kernels and initrds are content-addressed.
.context("Failed to build generation artifacts.")?; // Thus, this cannot overwrite files of old generation with different content.
self.install_generation(&generation)
.context("Failed to install generation.")?;
for (name, bootspec) in &generation.spec.bootspec.specialisations { for (name, bootspec) in &generation.spec.bootspec.specialisations {
let specialised_generation = generation.specialise(name, bootspec)?; let specialised_generation = generation.specialise(name, bootspec)?;
self.install_generation(&specialised_generation)
.context("Failed to install specialisation.")?;
}
}
build_generation_artifacts(self, &specialised_generation, generation_artifacts) // Sync files to persistent storage. This may improve the
.context("Failed to build generation artifacts for specialisation.")?; // chance of a consistent boot directory in case the system
} // crashes.
} let boot = File::open(&self.esp_paths.esp).context("Failed to open ESP root directory.")?;
syncfs(boot.as_raw_fd()).context("Failed to sync ESP filesystem.")?;
Ok(()) Ok(())
} }
/// Build the unsigned generation artifacts for a single generation. /// Install the given `Generation`.
/// ///
/// Stores the mapping from source to destination for the artifacts in the provided /// The kernel and initrd are content-addressed, and the stub name identifies the generation.
/// `GenerationArtifacts`. Does not install any files to the ESP. /// Hence, this function cannot overwrite files of other generations with different contents.
/// /// All installed files are added as garbage collector roots.
/// Because this function already has an complete view of all required paths in the ESP for fn install_generation(&mut self, generation: &Generation) -> Result<()> {
/// this generation, it stores all paths as GC roots. let tempdir = TempDir::new().context("Failed to create temporary directory.")?;
fn build_unsigned_generation_artifacts(
&mut self,
generation: &Generation,
generation_artifacts: &mut GenerationArtifacts,
) -> Result<()> {
let tempdir = &generation_artifacts.tempdir;
let bootspec = &generation.spec.bootspec.bootspec; let bootspec = &generation.spec.bootspec.bootspec;
let esp_gen_paths = EspGenerationPaths::new(&self.esp_paths, generation, self.arch)?; // The kernel is a file in /nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-linux-<version>/.
self.gc_roots.extend(esp_gen_paths.to_iter()); // (On x86, that file is called bzImage, but other architectures may differ.)
let kernel_dirname = bootspec
.kernel
.parent()
.and_then(Path::file_name)
.and_then(OsStr::to_str)
.context("Failed to extract the kernel directory name.")?;
let kernel_version = kernel_dirname
.rsplit('-')
.next()
.context("Failed to extract the kernel version.")?;
let initrd_content = fs::read( // Install the kernel and record its path on the ESP.
let kernel_target = self
.install_nixos_ca(&bootspec.kernel, &format!("kernel-{}", kernel_version))
.context("Failed to install the kernel.")?;
// Assemble and install the initrd, and record its path on the ESP.
let initrd_location = tempdir
.write_secure_file(
fs::read(
bootspec bootspec
.initrd .initrd
.as_ref() .as_ref()
.context("Lanzaboote does not support missing initrd yet")?, .context("Lanzaboote does not support missing initrd yet.")?,
)?; )
let initrd_location = tempdir .context("Failed to read the initrd.")?,
.write_secure_file(initrd_content) )
.context("Failed to copy initrd to tempfile.")?; .context("Failed to copy the initrd to the temporary directory.")?;
if let Some(initrd_secrets_script) = &bootspec.initrd_secrets { if let Some(initrd_secrets_script) = &bootspec.initrd_secrets {
append_initrd_secrets(initrd_secrets_script, &initrd_location)?; append_initrd_secrets(initrd_secrets_script, &initrd_location)?;
} }
let initrd_target = self
.install_nixos_ca(&initrd_location, &format!("initrd-{}", kernel_version))
.context("Failed to install the initrd.")?;
// The initrd and kernel don't need to be signed. The stub has their hashes embedded and // Assemble, sign and install the Lanzaboote stub.
// will refuse loading on hash mismatches.
//
// The kernel is not signed because systemd-boot could be tricked into loading the signed
// kernel in combination with an malicious unsigned initrd. This could be achieved because
// systemd-boot also honors the type #1 boot loader specification.
generation_artifacts.add_unsigned(&bootspec.kernel, &esp_gen_paths.kernel);
generation_artifacts.add_unsigned(&initrd_location, &esp_gen_paths.initrd);
Ok(())
}
/// Build the signed generation artifacts for a single generation.
///
/// Stores the mapping from source to destination for the artifacts in the provided
/// `GenerationArtifacts`. Does not install any files to the ESP.
///
/// This function expects an already pre-populated `GenerationArtifacts`. It can only be called
/// if ALL unsigned artifacts are already built and stored in `GenerationArtifacts`. More
/// specifically, this function can only be called after `build_unsigned_generation_artifacts`
/// has been executed.
fn build_signed_generation_artifacts(
&mut self,
generation: &Generation,
generation_artifacts: &mut GenerationArtifacts,
) -> Result<()> {
let tempdir = &generation_artifacts.tempdir;
let bootspec = &generation.spec.bootspec.bootspec;
let esp_gen_paths = EspGenerationPaths::new(&self.esp_paths, generation, self.arch)?;
let kernel_cmdline =
assemble_kernel_cmdline(&bootspec.init, bootspec.kernel_params.clone());
let os_release = OsRelease::from_generation(generation) let os_release = OsRelease::from_generation(generation)
.context("Failed to build OsRelease from generation.")?; .context("Failed to build OsRelease from generation.")?;
let os_release_path = tempdir let os_release_path = tempdir
.write_secure_file(os_release.to_string().as_bytes()) .write_secure_file(os_release.to_string().as_bytes())
.context("Failed to write os-release file.")?; .context("Failed to write os-release file.")?;
let kernel_cmdline =
let kernel_path: &Path = generation_artifacts assemble_kernel_cmdline(&bootspec.init, bootspec.kernel_params.clone());
.files
.get(&esp_gen_paths.kernel)
.context("Failed to retrieve kernel path from GenerationArtifacts.")?
.into();
let initrd_path = generation_artifacts
.files
.get(&esp_gen_paths.initrd)
.context("Failed to retrieve initrd path from GenerationArtifacts.")?
.into();
let lanzaboote_image = pe::lanzaboote_image( let lanzaboote_image = pe::lanzaboote_image(
tempdir, &tempdir,
&self.lanzaboote_stub, &self.lanzaboote_stub,
&os_release_path, &os_release_path,
&kernel_cmdline, &kernel_cmdline,
kernel_path, &bootspec.kernel,
initrd_path, &kernel_target,
&esp_gen_paths, &initrd_location,
&initrd_target,
&self.esp_paths.esp, &self.esp_paths.esp,
) )
.context("Failed to assemble lanzaboote image.")?; .context("Failed to assemble lanzaboote image.")?;
let stub_name = if let Some(specialisation_name) = generation.is_specialised() {
generation_artifacts.add_signed(&lanzaboote_image, &esp_gen_paths.lanzaboote_image); PathBuf::from(format!(
"nixos-generation-{}-specialisation-{}.efi",
generation, specialisation_name
))
} else {
PathBuf::from(format!("nixos-generation-{}.efi", generation))
};
let stub_target = self.esp_paths.linux.join(stub_name);
self.gc_roots.extend([&stub_target]);
install_signed(&self.key_pair, &lanzaboote_image, &stub_target)
.context("Failed to install the Lanzaboote stub.")?;
Ok(()) Ok(())
} }
/// Install a content-addressed file to the `EFI/nixos` directory on the ESP.
///
/// It is automatically added to the garbage collector roots.
/// The full path to the target file is returned.
fn install_nixos_ca(&mut self, from: &Path, label: &str) -> Result<PathBuf> {
let hash = file_hash(from).context("Failed to read the source file.")?;
let to = self.esp_paths.nixos.join(format!(
"{}-{}.efi",
label,
Base32Unpadded::encode_string(&hash)
));
self.gc_roots.extend([&to]);
install(from, &to)?;
Ok(to)
}
/// Install systemd-boot to ESP. /// Install systemd-boot to ESP.
/// ///
/// systemd-boot is only updated when a newer version is available OR when the currently /// systemd-boot is only updated when a newer version is available OR when the currently
@ -380,92 +325,6 @@ impl Installer {
} }
} }
/// A location in the ESP together with information whether the file
/// needs to be signed.
#[derive(Debug, Clone, PartialEq, Eq)]
enum FileSource {
SignedFile(PathBuf),
UnsignedFile(PathBuf),
}
impl<'a> From<&'a FileSource> for &'a Path {
fn from(value: &'a FileSource) -> Self {
match value {
FileSource::SignedFile(p) | FileSource::UnsignedFile(p) => p,
}
}
}
/// Stores the source and destination of all artifacts needed to install all generations.
///
/// The key feature of this data structure is that the mappings are automatically deduplicated
/// because they are stored in a HashMap using the destination as the key. Thus, there is only
/// unique destination paths.
///
/// This enables a two step installation process where all artifacts across all generations are
/// first collected and then installed. This deduplication in the collection phase reduces the
/// number of accesesses and writes to the ESP. More importantly, however, in the second step, all
/// paths on the ESP are uniquely determined and the images can be generated while being sure that
/// the hashes embedded in them will point to a valid file on the ESP because the file will not be
/// overwritten by a later generation.
struct GenerationArtifacts {
/// Temporary directory that stores all temporary files that are created when building the
/// GenerationArtifacts.
tempdir: TempDir,
/// A mapping from target location to source.
files: BTreeMap<PathBuf, FileSource>,
}
impl GenerationArtifacts {
fn new() -> Result<Self> {
Ok(Self {
tempdir: TempDir::new().context("Failed to create temporary directory.")?,
files: Default::default(),
})
}
/// Add a file to be installed.
///
/// Adding the same file multiple times with the same source is ok
/// and will drop the old source.
fn add_file(&mut self, from: FileSource, to: &Path) {
if let Some(_prev_from) = self.files.insert(to.to_path_buf(), from) {
// Should we log something here?
}
}
/// Add source and destination of a PE file to be signed.
///
/// Files are stored in the HashMap using their destination path as the key to ensure that the
/// destination paths are unique.
fn add_signed(&mut self, from: &Path, to: &Path) {
self.add_file(FileSource::SignedFile(from.to_path_buf()), to);
}
/// Add source and destination of an arbitrary file.
fn add_unsigned(&mut self, from: &Path, to: &Path) {
self.add_file(FileSource::UnsignedFile(from.to_path_buf()), to);
}
/// Install all files to the ESP.
fn install(&self, key_pair: &KeyPair) -> Result<()> {
for (to, from) in &self.files {
match from {
FileSource::SignedFile(from) => {
install_signed(key_pair, from, to).with_context(|| {
format!("Failed to sign and install from {from:?} to {to:?}")
})?
}
FileSource::UnsignedFile(from) => install(from, to)
.with_context(|| format!("Failed to install from {from:?} to {to:?}"))?,
}
}
Ok(())
}
}
/// Install a PE file. The PE gets signed in the process. /// Install a PE file. The PE gets signed in the process.
/// ///
/// If the file already exists at the destination, it is overwritten. /// If the file already exists at the destination, it is overwritten.

View File

@ -58,8 +58,9 @@ pub fn setup_generation_link_from_toplevel(
let bootspec = json!({ let bootspec = json!({
"org.nixos.bootspec.v1": { "org.nixos.bootspec.v1": {
"init": format!("init-v{}", version), "init": format!("init-v{}", version),
"initrd": toplevel.join("initrd"), // Normally, these are in the Nix store.
"kernel": toplevel.join("kernel"), "initrd": toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/initrd"),
"kernel": toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/kernel"),
"kernelParams": [ "kernelParams": [
"amd_iommu=on", "amd_iommu=on",
"amd_iommu=pt", "amd_iommu=pt",
@ -96,10 +97,12 @@ pub fn setup_generation_link_from_toplevel(
/// it (and when it goes out of scope). /// it (and when it goes out of scope).
pub fn setup_toplevel(tmpdir: &Path) -> Result<PathBuf> { pub fn setup_toplevel(tmpdir: &Path) -> Result<PathBuf> {
let system = Architecture::from_nixos_system(SYSTEM)?; let system = Architecture::from_nixos_system(SYSTEM)?;
// Generate a random toplevel name so that multiple toplevel paths can live alongside each // Generate a random toplevel name so that multiple toplevel paths can live alongside each
// other in the same directory. // other in the same directory.
let toplevel = tmpdir.join(format!("toplevel-{}", random_string(8))); let toplevel = tmpdir.join(format!("toplevel-{}", random_string(8)));
fs::create_dir(&toplevel)?; let fake_store_path = toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1");
fs::create_dir_all(&fake_store_path)?;
let test_systemd = systemd_location_from_env()?; let test_systemd = systemd_location_from_env()?;
let systemd_stub_filename = system.systemd_stub_filename(); let systemd_stub_filename = system.systemd_stub_filename();
@ -108,8 +111,8 @@ pub fn setup_toplevel(tmpdir: &Path) -> Result<PathBuf> {
systemd_stub_filename = systemd_stub_filename.display() systemd_stub_filename = systemd_stub_filename.display()
); );
let initrd_path = toplevel.join("initrd"); let initrd_path = fake_store_path.join("initrd");
let kernel_path = toplevel.join("kernel"); let kernel_path = fake_store_path.join("kernel");
let nixos_version_path = toplevel.join("nixos-version"); let nixos_version_path = toplevel.join("nixos-version");
let kernel_modules_path = toplevel.join("kernel-modules/lib/modules/6.1.1"); let kernel_modules_path = toplevel.join("kernel-modules/lib/modules/6.1.1");

View File

@ -29,17 +29,58 @@ fn keep_only_configured_number_of_generations() -> Result<()> {
assert_eq!(stub_count(), 3, "Wrong number of stubs after installation"); assert_eq!(stub_count(), 3, "Wrong number of stubs after installation");
assert_eq!( assert_eq!(
kernel_and_initrd_count(), kernel_and_initrd_count(),
6, 2,
"Wrong number of kernels & initrds after installation" "Wrong number of kernels & initrds after installation"
); );
// Call `lanzatool install` again with a config limit of 2 and assert that one is deleted. // Call `lanzatool install` again with a config limit of 2 and assert that one is deleted.
// In addition, the garbage kernel should be deleted as well.
let output1 = common::lanzaboote_install(2, esp_mountpoint.path(), generation_links)?; let output1 = common::lanzaboote_install(2, esp_mountpoint.path(), generation_links)?;
assert!(output1.status.success()); assert!(output1.status.success());
assert_eq!(stub_count(), 2, "Wrong number of stubs after gc."); assert_eq!(stub_count(), 2, "Wrong number of stubs after gc.");
assert_eq!( assert_eq!(
kernel_and_initrd_count(), kernel_and_initrd_count(),
4, 2,
"Wrong number of kernels & initrds after gc."
);
Ok(())
}
#[test]
fn delete_garbage_kernel() -> Result<()> {
let esp_mountpoint = tempdir()?;
let tmpdir = tempdir()?;
let profiles = tempdir()?;
let generation_links: Vec<PathBuf> = [1, 2, 3]
.into_iter()
.map(|v| {
common::setup_generation_link(tmpdir.path(), profiles.path(), v)
.expect("Failed to setup generation link")
})
.collect();
let stub_count = || count_files(&esp_mountpoint.path().join("EFI/Linux")).unwrap();
let kernel_and_initrd_count = || count_files(&esp_mountpoint.path().join("EFI/nixos")).unwrap();
// Install all 3 generations.
let output0 = common::lanzaboote_install(0, esp_mountpoint.path(), generation_links.clone())?;
assert!(output0.status.success());
// Create a garbage kernel, which should be deleted.
fs::write(
esp_mountpoint.path().join("EFI/nixos/kernel-garbage.efi"),
"garbage",
)?;
// Call `lanzatool install` again with a config limit of 2.
// In addition, the garbage kernel should be deleted as well.
let output1 = common::lanzaboote_install(2, esp_mountpoint.path(), generation_links)?;
assert!(output1.status.success());
assert_eq!(stub_count(), 2, "Wrong number of stubs after gc.");
assert_eq!(
kernel_and_initrd_count(),
2,
"Wrong number of kernels & initrds after gc." "Wrong number of kernels & initrds after gc."
); );

View File

@ -1,7 +1,7 @@
use std::fs; use std::path::PathBuf;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result}; use anyhow::Result;
use base32ct::{Base32Unpadded, Encoding};
use tempfile::{tempdir, TempDir}; use tempfile::{tempdir, TempDir};
mod common; mod common;
@ -67,7 +67,7 @@ fn overwrite_unsigned_images() -> Result<()> {
} }
#[test] #[test]
fn overwrite_unsigned_files() -> Result<()> { fn content_addressing_works() -> Result<()> {
let esp = tempdir()?; let esp = tempdir()?;
let tmpdir = tempdir()?; let tmpdir = tempdir()?;
let profiles = tempdir()?; let profiles = tempdir()?;
@ -76,24 +76,21 @@ fn overwrite_unsigned_files() -> Result<()> {
let generation_link = setup_generation_link_from_toplevel(&toplevel, profiles.path(), 1)?; let generation_link = setup_generation_link_from_toplevel(&toplevel, profiles.path(), 1)?;
let generation_links = vec![generation_link]; let generation_links = vec![generation_link];
let kernel_hash_source = hash_file(&toplevel.join("kernel")); let kernel_hash_source =
hash_file(&toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/kernel"));
let nixos_dir = esp.path().join("EFI/nixos");
let kernel_path = nixos_dir.join(nixos_path(toplevel.join("kernel"), "bzImage")?);
fs::create_dir_all(&nixos_dir)?;
fs::write(&kernel_path, b"Existing kernel")?;
let kernel_hash_existing = hash_file(&kernel_path);
let output0 = common::lanzaboote_install(1, esp.path(), generation_links)?; let output0 = common::lanzaboote_install(1, esp.path(), generation_links)?;
assert!(output0.status.success()); assert!(output0.status.success());
let kernel_hash_overwritten = hash_file(&kernel_path); let kernel_path = esp.path().join(format!(
"EFI/nixos/kernel-6.1.1-{}.efi",
Base32Unpadded::encode_string(&kernel_hash_source)
));
// Assert existing kernel was overwritten. // Implicitly assert that the content-addressed file actually exists.
assert_ne!(kernel_hash_existing, kernel_hash_overwritten); let kernel_hash = hash_file(&kernel_path);
// Assert overwritten kernel is the source kernel. // Assert the written kernel is the source kernel.
assert_eq!(kernel_hash_source, kernel_hash_overwritten); assert_eq!(kernel_hash_source, kernel_hash);
Ok(()) Ok(())
} }
@ -102,20 +99,3 @@ fn image_path(esp: &TempDir, version: u64) -> PathBuf {
esp.path() esp.path()
.join(format!("EFI/Linux/nixos-generation-{version}.efi")) .join(format!("EFI/Linux/nixos-generation-{version}.efi"))
} }
fn nixos_path(path: impl AsRef<Path>, name: &str) -> Result<PathBuf> {
let resolved = path
.as_ref()
.read_link()
.unwrap_or_else(|_| path.as_ref().into());
let parent_final_component = resolved
.parent()
.and_then(|x| x.file_name())
.and_then(|x| x.to_str())
.with_context(|| format!("Failed to extract final component from: {:?}", resolved))?;
let nixos_filename = format!("{}-{}.efi", parent_final_component, name);
Ok(PathBuf::from(nixos_filename))
}