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)) -}