From 240914d763c64fb2e4b932804e7273a95d085862 Mon Sep 17 00:00:00 2001 From: Alois Wohlschlager Date: Sat, 12 Aug 2023 15:23:21 +0200 Subject: [PATCH] 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. --- nix/tests/lanzaboote.nix | 59 ++--- rust/tool/Cargo.lock | 7 + rust/tool/shared/src/esp.rs | 100 +------- rust/tool/shared/src/pe.rs | 16 +- rust/tool/systemd/Cargo.toml | 1 + rust/tool/systemd/src/install.rs | 321 ++++++++------------------ rust/tool/systemd/tests/common/mod.rs | 13 +- rust/tool/systemd/tests/gc.rs | 45 +++- rust/tool/systemd/tests/install.rs | 48 ++-- 9 files changed, 191 insertions(+), 419 deletions(-) diff --git a/nix/tests/lanzaboote.nix b/nix/tests/lanzaboote.nix index 8791882..55e6ae7 100644 --- a/nix/tests/lanzaboote.nix +++ b/nix/tests/lanzaboote.nix @@ -133,30 +133,13 @@ let # `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 # 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 useSecureBoot; 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() - bootspec = json.loads(machine.succeed("cat /run/current-system/boot.json")).get('org.nixos.bootspec.v1') - 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("echo some_garbage_to_change_the_hash | tee -a ${appendCrapGlob} > /dev/null") machine.succeed("sync") machine.crash() machine.start() @@ -174,24 +157,12 @@ let # that would make the kernel still accept it. mkModifiedInitrdTest = { name, useSecureBoot }: mkHashMismatchTest { inherit name useSecureBoot; - - path = { - src = "bootspec.get('initrd')"; - dst = "convert_to_esp(bootspec.get('initrd'))"; - }; - - appendCrap = true; + appendCrapGlob = "/boot/EFI/nixos/initrd-*.efi"; }; mkModifiedKernelTest = { name, useSecureBoot }: mkHashMismatchTest { inherit name useSecureBoot; - - path = { - src = "bootspec.get('kernel')"; - dst = "convert_to_esp(bootspec.get('kernel'))"; - }; - - appendCrap = true; + appendCrapGlob = "/boot/EFI/nixos/kernel-*.efi"; }; in @@ -248,8 +219,9 @@ in # path) does not change. # # An unfortunate result of this NixOS feature is that updating the secrets - # without creating a new initrd might break previous generations. Lanzaboote - # has no control over that. + # without creating a new initrd might break previous generations. Verify 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 # because `lzbt` installs the specialisation of a generation AFTER installing @@ -279,12 +251,19 @@ in machine.start() machine.wait_for_unit("multi-user.target") - # Assert that only two boot files exists (a single kernel and a single - # initrd). If there are two initrds, the test would not be able to test - # updating the secret of an already existing initrd. - assert int(machine.succeed("ls -1 /boot/EFI/nixos | wc -l")) == 2 + # Assert that only three boot files exists (a single kernel and a two + # initrds). + assert int(machine.succeed("ls -1 /boot/EFI/nixos | wc -l")) == 3 - # 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") ''; }; diff --git a/rust/tool/Cargo.lock b/rust/tool/Cargo.lock index 6af64c2..47314fb 100644 --- a/rust/tool/Cargo.lock +++ b/rust/tool/Cargo.lock @@ -103,6 +103,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "base32ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396664016f30ad5ab761000391a5c0b436f7bfac738858263eb25897658b98c9" + [[package]] name = "bitflags" version = "1.3.2" @@ -501,6 +507,7 @@ version = "0.3.0" dependencies = [ "anyhow", "assert_cmd", + "base32ct", "clap", "expect-test", "filetime", diff --git a/rust/tool/shared/src/esp.rs b/rust/tool/shared/src/esp.rs index 73a1fa6..dbab2cc 100644 --- a/rust/tool/shared/src/esp.rs +++ b/rust/tool/shared/src/esp.rs @@ -1,13 +1,6 @@ -use std::{ - array::IntoIter, - path::{Path, PathBuf}, -}; - -use anyhow::{bail, Context, Result}; -use indoc::indoc; +use std::path::{Path, PathBuf}; use crate::architecture::Architecture; -use crate::generation::Generation; /// Generic ESP paths which can be specific to a bootloader pub trait EspPaths { @@ -23,94 +16,3 @@ pub trait EspPaths { /// Returns the path containing Linux EFI binaries 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>( - esp_paths: &P, - generation: &Generation, - system: Architecture, - ) -> Result { - 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, name: &str) -> Result { - 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(()) - } -} diff --git a/rust/tool/shared/src/pe.rs b/rust/tool/shared/src/pe.rs index 2a8ebb2..3333c61 100644 --- a/rust/tool/shared/src/pe.rs +++ b/rust/tool/shared/src/pe.rs @@ -8,7 +8,6 @@ use anyhow::{Context, Result}; use goblin::pe::PE; use tempfile::TempDir; -use crate::esp::EspGenerationPaths; use crate::utils::{file_hash, tmpname, SecureTempDirExt}; /// Assemble a lanzaboote image. @@ -20,9 +19,10 @@ pub fn lanzaboote_image( lanzaboote_stub: &Path, os_release: &Path, kernel_cmdline: &[String], - kernel_path: &Path, - initrd_path: &Path, - esp_gen_paths: &EspGenerationPaths, + kernel_source: &Path, + kernel_target: &Path, + initrd_source: &Path, + initrd_target: &Path, esp: &Path, ) -> Result { // 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_path_file = - tempdir.write_secure_file(esp_relative_uefi_path(esp, &esp_gen_paths.kernel)?)?; - let kernel_hash_file = tempdir.write_secure_file(file_hash(kernel_path)?.as_slice())?; + tempdir.write_secure_file(esp_relative_uefi_path(esp, kernel_target)?)?; + let kernel_hash_file = tempdir.write_secure_file(file_hash(kernel_source)?.as_slice())?; let initrd_path_file = - tempdir.write_secure_file(esp_relative_uefi_path(esp, &esp_gen_paths.initrd)?)?; - let initrd_hash_file = tempdir.write_secure_file(file_hash(initrd_path)?.as_slice())?; + tempdir.write_secure_file(esp_relative_uefi_path(esp, initrd_target)?)?; + let initrd_hash_file = tempdir.write_secure_file(file_hash(initrd_source)?.as_slice())?; let os_release_offs = stub_offset(lanzaboote_stub)?; let kernel_cmdline_offs = os_release_offs + file_size(os_release)?; diff --git a/rust/tool/systemd/Cargo.toml b/rust/tool/systemd/Cargo.toml index 33784be..0a0f240 100644 --- a/rust/tool/systemd/Cargo.toml +++ b/rust/tool/systemd/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] anyhow = "1.0.71" +base32ct = { version = "0.2.0", features = ["alloc"] } stderrlog = "0.5.4" log = { version = "0.4.18", features = ["std"] } clap = { version = "4.3.1", features = ["derive"] } diff --git a/rust/tool/systemd/src/install.rs b/rust/tool/systemd/src/install.rs index bdcb676..926ca9e 100644 --- a/rust/tool/systemd/src/install.rs +++ b/rust/tool/systemd/src/install.rs @@ -1,4 +1,5 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; +use std::ffi::OsStr; use std::fs::{self, File}; use std::os::fd::AsRawFd; use std::os::unix::prelude::PermissionsExt; @@ -7,6 +8,7 @@ use std::process::Command; use std::string::ToString; use anyhow::{anyhow, Context, Result}; +use base32ct::{Base32Unpadded, Encoding}; use nix::unistd::syncfs; use tempfile::TempDir; @@ -14,7 +16,7 @@ use crate::architecture::SystemdArchitectureExt; use crate::esp::SystemdEspPaths; use crate::version::SystemdVersion; use lanzaboote_tool::architecture::Architecture; -use lanzaboote_tool::esp::{EspGenerationPaths, EspPaths}; +use lanzaboote_tool::esp::EspPaths; use lanzaboote_tool::gc::Roots; use lanzaboote_tool::generation::{Generation, GenerationLink}; use lanzaboote_tool::os_release::OsRelease; @@ -74,9 +76,7 @@ impl Installer { .map(GenerationLink::from_path) .collect::>>()?; - // Sort the links by version. The links need to always be sorted to ensure the secrets of - // the latest generation are appended to the initrd when multiple generations point to the - // same initrd. + // Sort the links by version, so that the limit actually skips the oldest generations. links.sort_by_key(|l| l.version); // A configuration limit of 0 means there is no limit. @@ -129,64 +129,7 @@ impl Installer { } /// 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<()> { - // 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( - &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 .iter() .filter_map(|link| { @@ -214,122 +157,124 @@ impl Installer { } for generation in generations { - build_generation_artifacts(self, &generation, generation_artifacts) - .context("Failed to build generation artifacts.")?; - + // The kernels and initrds are content-addressed. + // 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 { let specialised_generation = generation.specialise(name, bootspec)?; - - build_generation_artifacts(self, &specialised_generation, generation_artifacts) - .context("Failed to build generation artifacts for specialisation.")?; + self.install_generation(&specialised_generation) + .context("Failed to install specialisation.")?; } } + // 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 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 - /// `GenerationArtifacts`. Does not install any files to the ESP. - /// - /// Because this function already has an complete view of all required paths in the ESP for - /// this generation, it stores all paths as GC roots. - fn build_unsigned_generation_artifacts( - &mut self, - generation: &Generation, - generation_artifacts: &mut GenerationArtifacts, - ) -> Result<()> { - let tempdir = &generation_artifacts.tempdir; - + /// The kernel and initrd are content-addressed, and the stub name identifies the generation. + /// Hence, this function cannot overwrite files of other generations with different contents. + /// All installed files are added as garbage collector roots. + fn install_generation(&mut self, generation: &Generation) -> Result<()> { + let tempdir = TempDir::new().context("Failed to create temporary directory.")?; let bootspec = &generation.spec.bootspec.bootspec; - let esp_gen_paths = EspGenerationPaths::new(&self.esp_paths, generation, self.arch)?; - self.gc_roots.extend(esp_gen_paths.to_iter()); + // The kernel is a file in /nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-linux-/. + // (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( - bootspec - .initrd - .as_ref() - .context("Lanzaboote does not support missing initrd yet")?, - )?; + // 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(initrd_content) - .context("Failed to copy initrd to tempfile.")?; + .write_secure_file( + fs::read( + bootspec + .initrd + .as_ref() + .context("Lanzaboote does not support missing initrd yet.")?, + ) + .context("Failed to read the initrd.")?, + ) + .context("Failed to copy the initrd to the temporary directory.")?; if let Some(initrd_secrets_script) = &bootspec.initrd_secrets { 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 - // 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()); - + // Assemble, sign and install the Lanzaboote stub. let os_release = OsRelease::from_generation(generation) .context("Failed to build OsRelease from generation.")?; let os_release_path = tempdir .write_secure_file(os_release.to_string().as_bytes()) .context("Failed to write os-release file.")?; - - let kernel_path: &Path = generation_artifacts - .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 kernel_cmdline = + assemble_kernel_cmdline(&bootspec.init, bootspec.kernel_params.clone()); let lanzaboote_image = pe::lanzaboote_image( - tempdir, + &tempdir, &self.lanzaboote_stub, &os_release_path, &kernel_cmdline, - kernel_path, - initrd_path, - &esp_gen_paths, + &bootspec.kernel, + &kernel_target, + &initrd_location, + &initrd_target, &self.esp_paths.esp, ) .context("Failed to assemble lanzaboote image.")?; - - generation_artifacts.add_signed(&lanzaboote_image, &esp_gen_paths.lanzaboote_image); + let stub_name = 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)) + }; + 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(()) } + /// 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 { + 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. /// /// 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, -} - -impl GenerationArtifacts { - fn new() -> Result { - 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. /// /// If the file already exists at the destination, it is overwritten. diff --git a/rust/tool/systemd/tests/common/mod.rs b/rust/tool/systemd/tests/common/mod.rs index ac17003..65beb2e 100644 --- a/rust/tool/systemd/tests/common/mod.rs +++ b/rust/tool/systemd/tests/common/mod.rs @@ -58,8 +58,9 @@ pub fn setup_generation_link_from_toplevel( let bootspec = json!({ "org.nixos.bootspec.v1": { "init": format!("init-v{}", version), - "initrd": toplevel.join("initrd"), - "kernel": toplevel.join("kernel"), + // Normally, these are in the Nix store. + "initrd": toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/initrd"), + "kernel": toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/kernel"), "kernelParams": [ "amd_iommu=on", "amd_iommu=pt", @@ -96,10 +97,12 @@ pub fn setup_generation_link_from_toplevel( /// it (and when it goes out of scope). pub fn setup_toplevel(tmpdir: &Path) -> Result { let system = Architecture::from_nixos_system(SYSTEM)?; + // Generate a random toplevel name so that multiple toplevel paths can live alongside each // other in the same directory. 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 systemd_stub_filename = system.systemd_stub_filename(); @@ -108,8 +111,8 @@ pub fn setup_toplevel(tmpdir: &Path) -> Result { systemd_stub_filename = systemd_stub_filename.display() ); - let initrd_path = toplevel.join("initrd"); - let kernel_path = toplevel.join("kernel"); + let initrd_path = fake_store_path.join("initrd"); + let kernel_path = fake_store_path.join("kernel"); let nixos_version_path = toplevel.join("nixos-version"); let kernel_modules_path = toplevel.join("kernel-modules/lib/modules/6.1.1"); diff --git a/rust/tool/systemd/tests/gc.rs b/rust/tool/systemd/tests/gc.rs index e969cd6..d988ade 100644 --- a/rust/tool/systemd/tests/gc.rs +++ b/rust/tool/systemd/tests/gc.rs @@ -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!( kernel_and_initrd_count(), - 6, + 2, "Wrong number of kernels & initrds after installation" ); // 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)?; assert!(output1.status.success()); assert_eq!(stub_count(), 2, "Wrong number of stubs after gc."); assert_eq!( 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 = [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." ); diff --git a/rust/tool/systemd/tests/install.rs b/rust/tool/systemd/tests/install.rs index ccb4c08..f9f6c03 100644 --- a/rust/tool/systemd/tests/install.rs +++ b/rust/tool/systemd/tests/install.rs @@ -1,7 +1,7 @@ -use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::Result; +use base32ct::{Base32Unpadded, Encoding}; use tempfile::{tempdir, TempDir}; mod common; @@ -67,7 +67,7 @@ fn overwrite_unsigned_images() -> Result<()> { } #[test] -fn overwrite_unsigned_files() -> Result<()> { +fn content_addressing_works() -> Result<()> { let esp = tempdir()?; let tmpdir = 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_links = vec![generation_link]; - let kernel_hash_source = hash_file(&toplevel.join("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 kernel_hash_source = + hash_file(&toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/kernel")); let output0 = common::lanzaboote_install(1, esp.path(), generation_links)?; 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. - assert_ne!(kernel_hash_existing, kernel_hash_overwritten); - // Assert overwritten kernel is the source kernel. - assert_eq!(kernel_hash_source, kernel_hash_overwritten); + // Implicitly assert that the content-addressed file actually exists. + let kernel_hash = hash_file(&kernel_path); + // Assert the written kernel is the source kernel. + assert_eq!(kernel_hash_source, kernel_hash); Ok(()) } @@ -102,20 +99,3 @@ fn image_path(esp: &TempDir, version: u64) -> PathBuf { esp.path() .join(format!("EFI/Linux/nixos-generation-{version}.efi")) } - -fn nixos_path(path: impl AsRef, name: &str) -> Result { - 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)) -}