diff --git a/flake.nix b/flake.nix index ff3621e..0aedd7c 100644 --- a/flake.nix +++ b/flake.nix @@ -50,14 +50,14 @@ { src , target ? null , doCheck ? true + , extraArgs ? { } }: let - cleanedSrc = craneLib.cleanCargoSource src; commonArgs = { - src = cleanedSrc; + inherit src; CARGO_BUILD_TARGET = target; inherit doCheck; - }; + } // extraArgs; cargoArtifacts = craneLib.buildDepsOnly commonArgs; in @@ -73,7 +73,7 @@ }; lanzabooteCrane = buildRustApp { - src = ./rust/lanzaboote; + src = craneLib.cleanCargoSource ./rust/lanzaboote; target = "x86_64-unknown-uefi"; doCheck = false; }; @@ -82,6 +82,13 @@ lanzatoolCrane = buildRustApp { src = ./rust/lanzatool; + extraArgs = { + TEST_SYSTEMD = pkgs.systemd; + checkInputs = with pkgs; [ + binutils-unwrapped + sbsigntool + ]; + }; }; lanzatool-unwrapped = lanzatoolCrane.package; @@ -134,6 +141,8 @@ lanzaboote lanzatool ]; + + TEST_SYSTEMD = pkgs.systemd; }; checks.x86_64-linux = { diff --git a/nix/modules/lanzaboote.nix b/nix/modules/lanzaboote.nix index 3942e5b..07ff7ab 100644 --- a/nix/modules/lanzaboote.nix +++ b/nix/modules/lanzaboote.nix @@ -5,11 +5,24 @@ let sbctlWithPki = pkgs.sbctl.override { databasePath = "/tmp/pki"; }; + + configurationLimit = if cfg.configurationLimit == null then 0 else cfg.configurationLimit; in { options.boot.lanzaboote = { enable = mkEnableOption "Enable the LANZABOOTE"; enrollKeys = mkEnableOption "Automatic enrollment of the keys using sbctl"; + configurationLimit = mkOption { + default = null; + example = 120; + type = types.nullOr types.int; + description = lib.mdDoc '' + Maximum number of latest generations in the boot menu. + Useful to prevent boot partition running out of disk space. + `null` means no limit i.e. all generations + that were not garbage collected yet. + ''; + }; pkiBundle = mkOption { type = types.nullOr types.path; description = "PKI bundle containg db, PK, KEK"; @@ -49,6 +62,7 @@ in ${cfg.package}/bin/lanzatool install \ --public-key ${cfg.publicKeyFile} \ --private-key ${cfg.privateKeyFile} \ + --configuration-limit ${toString configurationLimit} \ ${config.boot.loader.efi.efiSysMountPoint} \ /nix/var/nix/profiles/system-*-link ''; diff --git a/rust/lanzatool/Cargo.lock b/rust/lanzatool/Cargo.lock index 3b2bd88..a7663cb 100644 --- a/rust/lanzatool/Cargo.lock +++ b/rust/lanzatool/Cargo.lock @@ -20,6 +20,20 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" +[[package]] +name = "assert_cmd" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3d466004a8b4cb1bc34044240a2fd29d17607e2e3bd613eb44fd48e8100da3" +dependencies = [ + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "atty" version = "0.2.14" @@ -75,6 +89,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45ea9b00a7b3f2988e9a65ad3917e62123c38dba709b666506207be96d1790b" +dependencies = [ + "memchr", + "once_cell", + "regex-automata", + "serde", +] + [[package]] name = "cc" version = "1.0.77" @@ -140,6 +166,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.6" @@ -151,6 +183,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "either" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" + [[package]] name = "fastrand" version = "1.8.0" @@ -170,6 +214,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "goblin" version = "0.6.0" @@ -205,6 +260,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.4" @@ -216,14 +280,17 @@ name = "lanzatool" version = "0.1.0" dependencies = [ "anyhow", + "assert_cmd", "blake3", "bootspec", "clap", "goblin", "nix", + "rand", "serde", "serde_json", "tempfile", + "walkdir", ] [[package]] @@ -241,6 +308,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + [[package]] name = "nix" version = "0.25.0" @@ -271,6 +344,39 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "predicates" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" +dependencies = [ + "difflib", + "itertools", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f883590242d3c6fc5bf50299011695fa6590c2c70eac95ee1bdb9a733ad1a2" + +[[package]] +name = "predicates-tree" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ff541861505aabf6ea722d2131ee980b8276e10a1297b94e896dd8b621850d" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -313,6 +419,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -322,6 +458,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + [[package]] name = "remove_dir_all" version = "0.5.3" @@ -337,6 +479,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scroll" version = "0.11.0" @@ -434,6 +585,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95059e91184749cb66be6dc994f67f182b6d897cb3df74a5bf66b5e709295fd8" + [[package]] name = "typenum" version = "1.15.0" @@ -452,6 +609,32 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + [[package]] name = "winapi" version = "0.3.9" diff --git a/rust/lanzatool/Cargo.toml b/rust/lanzatool/Cargo.toml index 71705c1..45eb481 100644 --- a/rust/lanzatool/Cargo.toml +++ b/rust/lanzatool/Cargo.toml @@ -16,3 +16,8 @@ tempfile = "3.3.0" blake3 = "1.3.3" # TODO: wait for a upstream release and pin it. bootspec = { git = "https://github.com/DeterminateSystems/bootspec" } +walkdir = "2.3.2" + +[dev-dependencies] +assert_cmd = "2.0.7" +rand = "0.8.5" diff --git a/rust/lanzatool/src/cli.rs b/rust/lanzatool/src/cli.rs index 8745170..8f324cb 100644 --- a/rust/lanzatool/src/cli.rs +++ b/rust/lanzatool/src/cli.rs @@ -27,10 +27,14 @@ struct InstallCommand { #[arg(long)] private_key: PathBuf, + /// Configuration limit + #[arg(long, default_value_t = 1)] + configuration_limit: usize, + /// EFI system partition mountpoint (e.g. efiSysMountPoint) esp: PathBuf, - /// List of generations (e.g. /nix/var/nix/profiles/system-*-link) + /// List of generation links (e.g. /nix/var/nix/profiles/system-*-link) generations: Vec, } @@ -57,6 +61,7 @@ fn install(args: InstallCommand) -> Result<()> { install::Installer::new( PathBuf::from(lanzaboote_stub), key_pair, + args.configuration_limit, args.esp, args.generations, ) diff --git a/rust/lanzatool/src/esp.rs b/rust/lanzatool/src/esp.rs index 2f434d4..a8535aa 100644 --- a/rust/lanzatool/src/esp.rs +++ b/rust/lanzatool/src/esp.rs @@ -1,10 +1,13 @@ -use anyhow::{Context, Result}; +use std::array::IntoIter; use std::path::{Path, PathBuf}; +use anyhow::{Context, Result}; + use crate::generation::Generation; pub struct EspPaths { pub esp: PathBuf, + pub efi: PathBuf, pub nixos: PathBuf, pub kernel: PathBuf, pub initrd: PathBuf, @@ -19,32 +22,52 @@ pub struct EspPaths { impl EspPaths { pub fn new(esp: impl AsRef, generation: &Generation) -> Result { let esp = esp.as_ref(); - let esp_nixos = esp.join("EFI/nixos"); - let esp_linux = esp.join("EFI/Linux"); - let esp_systemd = esp.join("EFI/systemd"); - let esp_efi_fallback_dir = esp.join("EFI/BOOT"); + let efi = esp.join("EFI"); + let efi_nixos = efi.join("nixos"); + let efi_linux = efi.join("Linux"); + let efi_systemd = efi.join("systemd"); + let efi_efi_fallback_dir = efi.join("BOOT"); let bootspec = &generation.spec.bootspec; Ok(Self { esp: esp.to_path_buf(), - nixos: esp_nixos.clone(), - kernel: esp_nixos.join(nixos_path(&bootspec.kernel, "bzImage")?), - initrd: esp_nixos.join(nixos_path( + efi, + nixos: efi_nixos.clone(), + kernel: efi_nixos.join(nixos_path(&bootspec.kernel, "bzImage")?), + initrd: efi_nixos.join(nixos_path( bootspec .initrd .as_ref() .context("Lanzaboote does not support missing initrd yet")?, "initrd", )?), - linux: esp_linux.clone(), - lanzaboote_image: esp_linux.join(generation_path(generation)), - efi_fallback_dir: esp_efi_fallback_dir.clone(), - efi_fallback: esp_efi_fallback_dir.join("BOOTX64.EFI"), - systemd: esp_systemd.clone(), - systemd_boot: esp_systemd.join("systemd-bootx64.efi"), + linux: efi_linux.clone(), + lanzaboote_image: efi_linux.join(generation_path(generation)), + efi_fallback_dir: efi_efi_fallback_dir.clone(), + efi_fallback: efi_efi_fallback_dir.join("BOOTX64.EFI"), + systemd: efi_systemd.clone(), + systemd_boot: efi_systemd.join("systemd-bootx64.efi"), }) } + + /// Return the used file paths to store as garbage collection roots. + pub fn to_iter(&self) -> IntoIter<&PathBuf, 11> { + [ + &self.esp, + &self.efi, + &self.nixos, + &self.kernel, + &self.initrd, + &self.linux, + &self.lanzaboote_image, + &self.efi_fallback_dir, + &self.efi_fallback, + &self.systemd, + &self.systemd_boot, + ] + .into_iter() + } } fn nixos_path(path: impl AsRef, name: &str) -> Result { diff --git a/rust/lanzatool/src/gc.rs b/rust/lanzatool/src/gc.rs new file mode 100644 index 0000000..6238d99 --- /dev/null +++ b/rust/lanzatool/src/gc.rs @@ -0,0 +1,160 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use walkdir::{DirEntry, WalkDir}; + +/// Keeps track of the garbage collection roots. +/// +/// The internal HashSet contains all the paths still in use. These paths +/// are used to find all **unused** paths and delete them. +#[derive(Debug)] +pub struct Roots(HashSet); + +impl Roots { + pub fn new() -> Self { + Self(HashSet::new()) + } + + /// Extend the garbage collection roots. + /// + /// Not only the file paths of roots themselves, but also all parent directories that should + /// not be garbage collected need to be **explicitly** added to the roots. For example, if you + /// have a path: `rootdir/example/file.txt`, the three paths: `rootdir`, `rootdir/example`, and + /// `rootdir/example/file.txt` need to be added for the right files to be garbage collected. + pub fn extend<'a>(&mut self, other: impl IntoIterator) { + self.0.extend(other.into_iter().cloned()); + } + + fn in_use(&self, entry: Option<&DirEntry>) -> bool { + match entry { + Some(e) => self.0.contains(e.path()), + None => false, + } + } + + pub fn collect_garbage(&self, directory: impl AsRef) -> Result<()> { + // Find all the paths not used anymore. + let entries_not_in_use = WalkDir::new(directory.as_ref()) + .into_iter() + .filter(|e| !self.in_use(e.as_ref().ok())); + + // Remove all entries not in use. + for e in entries_not_in_use { + let entry = e?; + let path = entry.path(); + println!("'{}' not in use anymore. Removing...", path.display()); + + if path.is_dir() { + // If a directory is marked as unused all its children can be deleted too. + fs::remove_dir_all(path) + .with_context(|| format!("Failed to remove directory: {:?}", path))?; + } else { + // Ignore failing to remove path because the parent directory might have been removed before. + fs::remove_file(path).ok(); + }; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn keep_used_file() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let rootdir = create_dir(tmpdir.path().join("root"))?; + + let used_file = create_file(rootdir.join("root_file"))?; + + let mut roots = Roots::new(); + roots.extend(vec![&rootdir, &used_file]); + roots.collect_garbage(&rootdir)?; + + assert!(used_file.exists()); + Ok(()) + } + + #[test] + fn delete_unused_file() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let rootdir = create_dir(tmpdir.path().join("root"))?; + + let unused_file = create_file(rootdir.join("unused_file"))?; + + let mut roots = Roots::new(); + roots.extend(vec![&rootdir]); + roots.collect_garbage(&rootdir)?; + + assert!(!unused_file.exists()); + Ok(()) + } + + #[test] + fn delete_empty_unused_directory() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let rootdir = create_dir(tmpdir.path().join("root"))?; + + let unused_directory = create_dir(rootdir.join("unused_directory"))?; + + let mut roots = Roots::new(); + roots.extend(vec![&rootdir]); + roots.collect_garbage(&rootdir)?; + + assert!(!unused_directory.exists()); + Ok(()) + } + + #[test] + fn delete_unused_directory_with_unused_file_inside() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let rootdir = create_dir(tmpdir.path().join("root"))?; + + let unused_directory = create_dir(rootdir.join("unused_directory"))?; + let unused_file_in_directory = + create_file(unused_directory.join("unused_file_in_directory"))?; + + let mut roots = Roots::new(); + roots.extend(vec![&rootdir]); + roots.collect_garbage(&rootdir)?; + + assert!(!unused_directory.exists()); + assert!(!unused_file_in_directory.exists()); + Ok(()) + } + + #[test] + fn keep_used_dirctory_with_used_and_unused_file() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let rootdir = create_dir(tmpdir.path().join("root"))?; + + let used_directory = create_dir(rootdir.join("used_directory"))?; + let used_file_in_directory = create_file(used_directory.join("used_file_in_directory"))?; + let unused_file_in_directory = + create_file(used_directory.join("unused_file_in_directory"))?; + + let mut roots = Roots::new(); + roots.extend(vec![&rootdir, &used_directory, &used_file_in_directory]); + roots.collect_garbage(&rootdir)?; + + assert!(used_directory.exists()); + assert!(used_file_in_directory.exists()); + assert!(!unused_file_in_directory.exists()); + Ok(()) + } + + fn create_file(path: PathBuf) -> Result { + fs::File::create(&path)?; + Ok(path) + } + + fn create_dir(path: PathBuf) -> Result { + fs::create_dir(&path)?; + Ok(path) + } +} diff --git a/rust/lanzatool/src/generation.rs b/rust/lanzatool/src/generation.rs index 4bbc5d1..0acb234 100644 --- a/rust/lanzatool/src/generation.rs +++ b/rust/lanzatool/src/generation.rs @@ -1,6 +1,3 @@ -use serde::de::IntoDeserializer; -use serde::{Deserialize, Serialize}; - use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -9,6 +6,8 @@ use anyhow::{anyhow, Context, Result}; use bootspec::generation::Generation as BootspecGeneration; use bootspec::BootJson; use bootspec::SpecialisationName; +use serde::de::IntoDeserializer; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecureBootExtension { @@ -22,6 +21,14 @@ pub struct ExtendedBootJson { pub extensions: SecureBootExtension, } +/// A system configuration. +/// +/// Can be built from a GenerationLink. +/// +/// NixOS represents a generation as a symlink to a toplevel derivation. This toplevel derivation +/// contains most of the information necessary to install the generation onto the EFI System +/// Partition. The only information missing is the version number which is encoded in the file name +/// of the generation link. #[derive(Debug)] pub struct Generation { /// Profile symlink index @@ -33,17 +40,8 @@ pub struct Generation { } impl Generation { - fn extract_extensions(bootspec: &BootJson) -> Result { - Ok(Deserialize::deserialize( - bootspec.extensions.get("lanzaboote") - .context("Failed to extract Lanzaboote-specific extension from Bootspec, missing lanzaboote field in `extensions`")? - .clone() - .into_deserializer() - )?) - } - - pub fn from_toplevel(toplevel: impl AsRef) -> Result { - let bootspec_path = toplevel.as_ref().join("boot.json"); + pub fn from_link(link: &GenerationLink) -> Result { + let bootspec_path = link.path.join("boot.json"); let generation: BootspecGeneration = serde_json::from_slice( &fs::read(bootspec_path).context("Failed to read bootspec file")?, ) @@ -56,7 +54,7 @@ impl Generation { let extensions = Self::extract_extensions(&bootspec)?; Ok(Self { - version: parse_version(toplevel)?, + version: link.version, specialisation_name: None, spec: ExtendedBootJson { bootspec, @@ -65,6 +63,15 @@ impl Generation { }) } + fn extract_extensions(bootspec: &BootJson) -> Result { + Ok(Deserialize::deserialize( + bootspec.extensions.get("lanzaboote") + .context("Failed to extract Lanzaboote-specific extension from Bootspec, missing lanzaboote field in `extensions`")? + .clone() + .into_deserializer() + )?) + } + pub fn specialise(&self, name: &SpecialisationName, bootspec: &BootJson) -> Result { Ok(Self { version: self.version, @@ -87,6 +94,25 @@ impl fmt::Display for Generation { } } +/// A link pointing to a generation. +/// +/// Can be built from a symlink in /nix/var/nix/profiles/ alone because the name of the +/// symlink enocdes the version number. +#[derive(Debug)] +pub struct GenerationLink { + pub version: u64, + pub path: PathBuf, +} + +impl GenerationLink { + pub fn from_path(path: impl AsRef) -> Result { + Ok(Self { + version: parse_version(&path).context("Failed to parse version")?, + path: PathBuf::from(path.as_ref()), + }) + } +} + /// Parse version number from a path. /// /// Expects a path in the format of "system-{version}-link". diff --git a/rust/lanzatool/src/install.rs b/rust/lanzatool/src/install.rs index a4bdc3d..705aee7 100644 --- a/rust/lanzatool/src/install.rs +++ b/rust/lanzatool/src/install.rs @@ -8,37 +8,71 @@ use nix::unistd::sync; use tempfile::tempdir; use crate::esp::EspPaths; -use crate::generation::Generation; +use crate::gc::Roots; +use crate::generation::{Generation, GenerationLink}; use crate::pe; use crate::signature::KeyPair; pub struct Installer { + gc_roots: Roots, lanzaboote_stub: PathBuf, key_pair: KeyPair, + configuration_limit: usize, esp: PathBuf, - generations: Vec, + generation_links: Vec, } impl Installer { pub fn new( lanzaboote_stub: PathBuf, key_pair: KeyPair, + configuration_limit: usize, esp: PathBuf, - generations: Vec, + generation_links: Vec, ) -> Self { Self { + gc_roots: Roots::new(), lanzaboote_stub, key_pair, + configuration_limit, esp, - generations, + generation_links, } } - pub fn install(&self) -> Result<()> { - for toplevel in &self.generations { - let generation_result = Generation::from_toplevel(toplevel) - .with_context(|| format!("Failed to build generation from toplevel: {toplevel:?}")); + pub fn install(&mut self) -> Result<()> { + let mut links = self + .generation_links + .iter() + .map(GenerationLink::from_path) + .collect::>>()?; + // A configuration limit of 0 means there is no limit. + if self.configuration_limit > 0 { + // Sort the links by version. + links.sort_by_key(|l| l.version); + + // Only install the number of generations configured. + links = links + .into_iter() + .rev() + .take(self.configuration_limit) + .collect() + }; + self.install_links(links)?; + + self.gc_roots.collect_garbage(&self.esp)?; + + Ok(()) + } + + fn install_links(&mut self, links: Vec) -> Result<()> { + for link in links { + let generation_result = Generation::from_link(&link) + .with_context(|| format!("Failed to build generation from link: {link:?}")); + + // Ignore failing to read a generation so that old malformed generations do not stop + // lanzatool from working. let generation = match generation_result { Ok(generation) => generation, Err(e) => { @@ -61,15 +95,15 @@ impl Installer { .context("Failed to install specialisation")?; } } - Ok(()) } - fn install_generation(&self, generation: &Generation) -> Result<()> { + fn install_generation(&mut self, generation: &Generation) -> Result<()> { let bootspec = &generation.spec.bootspec; let secureboot_extensions = &generation.spec.extensions; let esp_paths = EspPaths::new(&self.esp, generation)?; + self.gc_roots.extend(esp_paths.to_iter()); let kernel_cmdline = assemble_kernel_cmdline(&bootspec.init, bootspec.kernel_params.clone()); diff --git a/rust/lanzatool/src/main.rs b/rust/lanzatool/src/main.rs index 80513ab..06de0c8 100644 --- a/rust/lanzatool/src/main.rs +++ b/rust/lanzatool/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod esp; +mod gc; mod generation; mod install; mod pe; diff --git a/rust/lanzatool/tests/common/mod.rs b/rust/lanzatool/tests/common/mod.rs new file mode 100644 index 0000000..5df635f --- /dev/null +++ b/rust/lanzatool/tests/common/mod.rs @@ -0,0 +1,155 @@ +use std::ffi::OsStr; +use std::fs; +use std::io::Write; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; +use std::process::Output; + +use anyhow::{Context, Result}; +use assert_cmd::Command; +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; +use serde_json::json; + +/// Create a mock generation link. +/// +/// Creates the generation link using the specified version inside a mock profiles directory +/// (mimicking /nix/var/nix/profiles). Returns the path to the generation link. +pub fn setup_generation_link( + tmpdir: &Path, + profiles_directory: &Path, + version: u64, +) -> Result { + let toplevel = setup_toplevel(tmpdir).context("Failed to setup toplevel")?; + + let bootspec = json!({ + "v1": { + "init": format!("init-v{}", version), + "initrd": toplevel.join("initrd"), + "kernel": toplevel.join("kernel"), + "kernelParams": [ + "amd_iommu=on", + "amd_iommu=pt", + "iommu=pt", + "kvm.ignore_msrs=1", + "kvm.report_ignored_msrs=0", + "udev.log_priority=3", + "systemd.unified_cgroup_hierarchy=1", + "loglevel=4" + ], + "label": "LanzaOS", + "toplevel": toplevel, + "system": "x86_64-linux", + "specialisation": {}, + "extensions": { + "lanzaboote": { "osRelease": toplevel.join("os-release") } + } + } + }); + + let generation_link_path = profiles_directory.join(format!("system-{}-link", version)); + fs::create_dir(&generation_link_path)?; + + let bootspec_path = generation_link_path.join("boot.json"); + let mut file = fs::File::create(bootspec_path)?; + file.write_all(&serde_json::to_vec(&bootspec)?)?; + + Ok(generation_link_path) +} + +/// Setup a mock toplevel inside a temporary directory. +/// +/// Accepts the temporary directory as a parameter so that the invoking function retains control of +/// it (and when it goes out of scope). +fn setup_toplevel(tmpdir: &Path) -> Result { + // 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 test_systemd = systemd_location_from_env()?; + let test_systemd_stub = format!("{test_systemd}/lib/systemd/boot/efi/linuxx64.efi.stub"); + + let initrd_path = toplevel.join("initrd"); + let kernel_path = toplevel.join("kernel"); + let systemd_path = toplevel.join("systemd"); + let os_release_path = toplevel.join("os-release"); + + // To simplify the test setup, we use the systemd stub for all PE binaries used by lanzatool. + // Lanzatool doesn't care whether its actually a kernel or initrd but only whether it can + // manipulate the PE binary with objcopy and/or sign it with sbsigntool. For testing lanzatool + // in isolation this should suffice. + fs::copy(&test_systemd_stub, initrd_path)?; + fs::copy(&test_systemd_stub, kernel_path)?; + symlink(&test_systemd, systemd_path)?; + setup_os_release(&os_release_path).context("Failed to setup os-release")?; + + Ok(toplevel) +} + +fn random_string(length: usize) -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect() +} + +fn setup_os_release(path: &Path) -> Result<()> { + let content = r#" + ID=lanzaos + NAME=LanzaOS + PRETTY_NAME="LanzaOS 23.05 (Goat)" + VERSION="23.05 (Goat)" + "#; + + let mut file = fs::File::create(path)?; + file.write_all(content.as_bytes())?; + Ok(()) +} + +/// Call the `lanzaboote install` command. +pub fn lanzaboote_install( + config_limit: u64, + esp_mountpoint: &Path, + generation_links: impl IntoIterator>, +) -> Result { + // To simplify the test setup, we use the systemd stub here instead of the lanzaboote stub. See + // the comment in setup_toplevel for details. + let test_systemd = systemd_location_from_env()?; + let test_systemd_stub = format!("{test_systemd}/lib/systemd/boot/efi/linuxx64.efi.stub"); + + let mut cmd = Command::cargo_bin("lanzatool")?; + let output = cmd + .env("LANZABOOTE_STUB", test_systemd_stub) + .arg("install") + .arg("--public-key") + .arg("tests/fixtures/uefi-keys/db.pem") + .arg("--private-key") + .arg("tests/fixtures/uefi-keys/db.key") + .arg("--configuration-limit") + .arg(config_limit.to_string()) + .arg(esp_mountpoint) + .args(generation_links) + .output()?; + + // Print debugging output. + // This is a weird hack to make cargo test capture the output. + // See https://github.com/rust-lang/rust/issues/12309 + print!("{}", String::from_utf8(output.stdout.clone())?); + print!("{}", String::from_utf8(output.stderr.clone())?); + + // Also walk the entire ESP mountpoint and print each path for debugging + for entry in walkdir::WalkDir::new(esp_mountpoint) { + println!("{}", entry?.path().display()); + } + + Ok(output) +} + +/// Read location of systemd installation from an environment variable. +fn systemd_location_from_env() -> Result { + let error_msg = "TEST_SYSTEMD environment variable is not set. TEST_SYSTEMD has to point to a systemd installation. +On a system with Nix installed, you can set it with: export TEST_SYSTEMD=$(nix-build '' -A systemd)"; + std::env::var("TEST_SYSTEMD").context(error_msg) +} diff --git a/rust/lanzatool/tests/fixtures/uefi-keys/db.key b/rust/lanzatool/tests/fixtures/uefi-keys/db.key new file mode 100644 index 0000000..c2a5ab4 --- /dev/null +++ b/rust/lanzatool/tests/fixtures/uefi-keys/db.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +3cEKjh9NywA7eLWDxRxEf/vq3vdWeFY= +-----END PRIVATE KEY----- diff --git a/rust/lanzatool/tests/fixtures/uefi-keys/db.pem b/rust/lanzatool/tests/fixtures/uefi-keys/db.pem new file mode 100644 index 0000000..d383ba0 --- /dev/null +++ b/rust/lanzatool/tests/fixtures/uefi-keys/db.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE+TCCAuGgAwIBAgIRAPmrvtqrYB4tl9V5HFxxLYowDQYJKoZIhvcNAQELBQAw +LjEVMBMGA1UEBhMMRGF0YWJhc2UgS2V5MRUwEwYDVQQDEwxEYXRhYmFzZSBLZXkw +HhcNMjIxMTIzMTI1NjU1WhcNMjcxMTIzMTI1NjU1WjAuMRUwEwYDVQQGEwxEYXRh +YmFzZSBLZXkxFTATBgNVBAMTDERhdGFiYXNlIEtleTCCAiIwDQYJKoZIhvcNAQEB +BQADggIPADCCAgoCggIBAK4tln0LCpqCkQeKqN1ddrxffWH+hNNwA3re40TRWTUp +3lowTMTOGukkfVpyqPpx8YyxEuxWL5r0xTxxfMpmGOz2tS5/KkzAKVIFnkf1LXWY +kMXS87ZYFP7qlt5T+Af9clzjgIVtjkhJAnF/gmJDzqkqsY+MWrRKympwSX8K957F +vkuNIreohHbWb6P4C/7sgtYBEWGALOO15nIoeGZT4cGnp40Mcz/6xaAnAfBMOG0q +A/bIxVllrkhIcc3BhShRhmVL8XZHXxcS11c4hUmzAtdNKrhl2l3A3AsdJbuVsN9q +AMMZ1+9BkQT/0q6z9coOib+8aA790WKFF+bbVDRgr6gaMK12MqGa3UkKFf1pCylH +xLNaPtxPFU11g1IK0z47OTxTJ93jH0x3g1uylc8zRs8Kbmry2PSwEQ3CEnoKDD4y +ito3ffhh3pxySUL7eHNFCPgVy6BUavHm9DKMfyB00iAGf5u9qSSGzfy4fcTXMrt2 +sbW/3YfOXLKtf0OyUTx5TZZavcqGU10BLGK1pG3uvdn12tf8KGUXoxx8C/0NWtQS +KVkdItqjvKNPx1UgpLHZ2NttfGC0TP4vpphpj309m+A7/ItS8Uou5dnsnfcE6Kie +y2jCd+mZHEvpC13F5O6eIrOTKiXu4h6etyh6qYX+wqFG8ew8yvJ2oVhw4PkZw01z +AgMBAAGjEjAQMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsFAAOCAgEAbh22 +GBG/orDJrOClvt8qABL60ojnilLh4J5BwGDwBcXE/wETvXb+o3GsjTqqHdXYCQx0 +e3SpR/xQCfKzuaE4ysaszKFcNbhaezLmo/pzD2v0UA6Jr56re/jkYMtLQL4+LAUP +kCH66vc6qETQwtAzV3CYQ5TirwLLD6z3luPhm2FzyXTU9eydbsRp7FuSKq8mVXKP +cskrVJSjWD7fOsy/sZq3S+xmA3w1C8XRAgz+xlfIQkuu0mAa3jJ3JGjDyu1lAZ6N +oUc897npQ2tpYyGRQ/zOmKSE9oUlcEqXLPk2nDgenhG0oAdM/jJ54rnuR2x3zhBx +gyPEvWVzjzX8E//9Kc6VocisnI/tckibUPHlMJFHFBXiU8m6vO28cCRl4tRsH/K9 +jOnm+ztraiXI7mqBfTchR7Ga0EmnWFf38y3/YkrbVYbjK6o/+/T72Sje0MfCAvTl +KLOtvaQWQm36KP/2PH3DZ/k/v8+U76oeUBoI/DDH8E4rR1cUORvUYH8MZyPIKM4e +GN3+/zjr5gw70iLC/p8C+8rCzxwoernIMcFlL+SaeybnzXvH3VqO/XAFA/lxMu4V +K7Gzrtr5n+FiSTXytdIu18o6Av65XN5hzwQvVeWIx/E5Jry/JlSQgho8CnqLK8g5 +USdRgvRJN7xJM3szP93f7aNgIYSys2cCxtZxunU= +-----END CERTIFICATE----- diff --git a/rust/lanzatool/tests/gc.rs b/rust/lanzatool/tests/gc.rs new file mode 100644 index 0000000..1763b39 --- /dev/null +++ b/rust/lanzatool/tests/gc.rs @@ -0,0 +1,38 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use tempfile::tempdir; + +mod common; + +#[test] +fn keep_only_configured_number_of_generations() -> 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(); + + // Install all 3 generations. + let output0 = common::lanzaboote_install(0, esp_mountpoint.path(), generation_links.clone())?; + assert!(output0.status.success()); + assert_eq!(stub_count(), 3); + + // Call `lanzatool install` again with a config limit of 2 and assert that one is deleted. + let output1 = common::lanzaboote_install(2, esp_mountpoint.path(), generation_links)?; + assert!(output1.status.success()); + assert_eq!(stub_count(), 2); + + Ok(()) +} + +fn count_files(path: &Path) -> Result { + Ok(fs::read_dir(path)?.count()) +}