Merge pull request #204 from alois31/no-overwrite

Stop most overwriting in the ESP
This commit is contained in:
nikstur 2023-10-04 11:07:38 +02:00 committed by GitHub
commit ac43ac3024
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 362 additions and 457 deletions

View File

@ -14,6 +14,10 @@ It is recommended run a garbage collection regularly, and monitor the ESP usage
**Warning:** It is recommended to not delete the currently booted kernel and initrd, and to not reboot the system before running `nixos-rebuild boot` again, to minimize the risk of accidentally rendering the system unbootable. **Warning:** It is recommended to not delete the currently booted kernel and initrd, and to not reboot the system before running `nixos-rebuild boot` again, to minimize the risk of accidentally rendering the system unbootable.
**Note:** When upgrading Lanzaboote from version 0.3.0, or from git master prior to the merge of PR #204, ESP space usage is temporarily doubled.
Hence it is possible for this error to occur even if there was plenty (but less than half) free space available prior to the installation.
In this case, it is not necessary to delete any generations, and you can proceed directly to deleting some kernels and initrds before running the installation again.
## Power failed during bootloader installation, and now the system does not boot any more ## Power failed during bootloader installation, and now the system does not boot any more
Due to the shortcomings of the FAT32 filesystem, in rare cases, it is possible for the ESP to become corrupted after power loss. Due to the shortcomings of the FAT32 filesystem, in rare cases, it is possible for the ESP to become corrupted after power loss.
@ -26,10 +30,15 @@ In this case, the steps below will not help, and standard rollback procedures sh
### The system can still boot an older generation ### The system can still boot an older generation
In case an older generation still works, the recovery can be carried out from within the booted system. In case an older generation still works, the recovery can be carried out from within the booted system.
Run `nix-shell -p openssl sbctl` to ensure the tools required for recovery are available.
1. Run `nixos-rebuild boot`. 1. Run `sudo sbctl verify /boot/EFI/Linux/nixos-generation-*.efi` to check the Lanzaboote stubs.
This should reinstall all generations and thus overwrite the corrupted files. Files that have a crossmark on their left are corrupted and must be deleted.
2. Reboot the system, it should now work again. 2. Run `for file in /boot/EFI/nixos/*.efi; do hash=$(openssl dgst -sha256 -binary "$file" | base32 | tr -d = | LC_ALL=C tr [:upper:] [:lower:]); if [[ $file != *$hash.efi ]]; then echo $file; fi; done` to check the kernels and initrds.
Any files that are printed are corrupted and must be deleted.
3. Run `nixos-rebuild boot`.
This should reinstate all files that are required for the newer generations to boot.
4. Reboot the system, it should now work again.
### The system cannot boot any generation anymore ### The system cannot boot any generation anymore
@ -45,11 +54,12 @@ A more recent medium must be used for the recovery procedure to work reliably.
3. Mount all partitions belonging to the system to be recovered under `/mnt`, just like you would for installation. 3. Mount all partitions belonging to the system to be recovered under `/mnt`, just like you would for installation.
1. In case the ESP does not mount, or only mounts in read-only mode, due to corruption, try `fsck.fat` first. 1. In case the ESP does not mount, or only mounts in read-only mode, due to corruption, try `fsck.fat` first.
If that fails as well or the ESP still does not mount, it needs to be reformatted using `mkfs.fat`. If that fails as well or the ESP still does not mount, it needs to be reformatted using `mkfs.fat`.
4. Enter the recovery shell by running `nixos-enter`. 4. Delete the corrupted files on the ESP, using `rm -fr /mnt/boot/EFI/Linux/nixos-generation-*.efi /mnt/boot/efi/nixos`.
5. Enter the recovery shell by running `nixos-enter`.
Then, run `nixos-rebuild boot` to install the bootloader again. Then, run `nixos-rebuild boot` to install the bootloader again.
5. Exit the recovery shell and unmount all filesystems. 6. Exit the recovery shell and unmount all filesystems.
6. Reboot the system to verify that everything works again. 7. Reboot the system to verify that everything works again.
7. Enable Secure Boot again in the firmware settings. 8. Enable Secure Boot again in the firmware settings.
## The system doesn't boot with Secure Boot enabled ## The system doesn't boot with Secure Boot enabled

View File

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

7
rust/tool/Cargo.lock generated
View File

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

View File

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

View File

@ -84,7 +84,14 @@ impl Generation {
.build_time .build_time
.map(|x| x.to_string()) .map(|x| x.to_string())
.unwrap_or_else(|| String::from("Unknown")); .unwrap_or_else(|| String::from("Unknown"));
format!("Generation {}, Built on {}", self.version, build_time) if self.is_specialised().is_some() {
format!(
"Generation {}-specialised, Built on {}",
self.version, build_time
)
} else {
format!("Generation {}, Built on {}", self.version, build_time)
}
} }
} }

View File

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

View File

@ -7,11 +7,14 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.71" anyhow = "1.0.71"
base32ct = { version = "0.2.0", features = ["alloc"] }
stderrlog = "0.5.4" stderrlog = "0.5.4"
log = { version = "0.4.18", features = ["std"] } log = { version = "0.4.18", features = ["std"] }
clap = { version = "4.3.1", features = ["derive"] } clap = { version = "4.3.1", features = ["derive"] }
lanzaboote_tool = { path = "../shared" } lanzaboote_tool = { path = "../shared" }
indoc = "2.0.1" indoc = "2.0.1"
serde_json = "1.0.96"
sha2 = "0.10.6"
tempfile = "3.5.0" tempfile = "3.5.0"
nix = { version = "0.26.2", default-features = false, features = [ "fs" ] } nix = { version = "0.26.2", default-features = false, features = [ "fs" ] }
@ -20,8 +23,6 @@ assert_cmd = "2.0.11"
expect-test = "1.4.1" expect-test = "1.4.1"
filetime = "0.2.21" filetime = "0.2.21"
rand = "0.8.5" rand = "0.8.5"
serde_json = "1.0.96"
goblin = "0.6.1" goblin = "0.6.1"
serde = { version = "1.0.163", features = ["derive"] } serde = { version = "1.0.163", features = ["derive"] }
walkdir = "2.3.3" walkdir = "2.3.3"
sha2 = "0.10.6"

View File

@ -1,20 +1,23 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fs::{self, File}; use std::fs::{self, File};
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use std::os::unix::prelude::PermissionsExt; use std::os::unix::prelude::{OsStrExt, PermissionsExt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::string::ToString; use std::string::ToString;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use base32ct::{Base32Unpadded, Encoding};
use nix::unistd::syncfs; use nix::unistd::syncfs;
use sha2::{Digest, Sha256};
use tempfile::TempDir; use tempfile::TempDir;
use crate::architecture::SystemdArchitectureExt; use crate::architecture::SystemdArchitectureExt;
use crate::esp::SystemdEspPaths; use crate::esp::SystemdEspPaths;
use crate::version::SystemdVersion; use crate::version::SystemdVersion;
use lanzaboote_tool::architecture::Architecture; use lanzaboote_tool::architecture::Architecture;
use lanzaboote_tool::esp::{EspGenerationPaths, EspPaths}; use lanzaboote_tool::esp::EspPaths;
use lanzaboote_tool::gc::Roots; use lanzaboote_tool::gc::Roots;
use lanzaboote_tool::generation::{Generation, GenerationLink}; use lanzaboote_tool::generation::{Generation, GenerationLink};
use lanzaboote_tool::os_release::OsRelease; use lanzaboote_tool::os_release::OsRelease;
@ -74,9 +77,7 @@ impl Installer {
.map(GenerationLink::from_path) .map(GenerationLink::from_path)
.collect::<Result<Vec<GenerationLink>>>()?; .collect::<Result<Vec<GenerationLink>>>()?;
// Sort the links by version. The links need to always be sorted to ensure the secrets of // Sort the links by version, so that the limit actually skips the oldest generations.
// the latest generation are appended to the initrd when multiple generations point to the
// same initrd.
links.sort_by_key(|l| l.version); links.sort_by_key(|l| l.version);
// A configuration limit of 0 means there is no limit. // A configuration limit of 0 means there is no limit.
@ -129,64 +130,7 @@ impl Installer {
} }
/// Install all generations from the provided `GenerationLinks`. /// Install all generations from the provided `GenerationLinks`.
///
/// Iterates over the links twice:
/// (1) First, building all unsigned artifacts and storing the mapping from source to
/// destination in `GenerationArtifacts`. `GenerationArtifacts` ensures that there are no
/// duplicate destination paths and thus ensures that the hashes embedded in the lanzaboote
/// image do not get invalidated because the files to which they point get overwritten by a
/// later generation.
/// (2) Second, building all signed artifacts using the previously built mapping from source to
/// destination in the `GenerationArtifacts`.
///
/// This way, in the second step, all paths and thus all hashes for all generations are already
/// known. The signed files can now be constructed with known good hashes **across** all
/// generations.
fn install_generations_from_links(&mut self, links: &[GenerationLink]) -> Result<()> { fn install_generations_from_links(&mut self, links: &[GenerationLink]) -> Result<()> {
// This struct must live for the entire lifetime of this function so that the contained
// tempdir does not go out of scope and thus does not get deleted.
let mut generation_artifacts =
GenerationArtifacts::new().context("Failed to create GenerationArtifacts.")?;
self.build_generation_artifacts_from_links(
&mut generation_artifacts,
links,
Self::build_unsigned_generation_artifacts,
)
.context("Failed to build unsigned generation artifacts.")?;
self.build_generation_artifacts_from_links(
&mut generation_artifacts,
links,
Self::build_signed_generation_artifacts,
)
.context("Failed to build signed generation artifacts.")?;
generation_artifacts
.install(&self.key_pair)
.context("Failed to install files.")?;
// Sync files to persistent storage. This may improve the
// chance of a consistent boot directory in case the system
// crashes.
let boot = File::open(&self.esp_paths.esp).context("Failed to open ESP root directory.")?;
syncfs(boot.as_raw_fd()).context("Failed to sync ESP filesystem.")?;
Ok(())
}
/// Build all generation artifacts from a list of `GenerationLink`s.
///
/// This function accepts a closure to build the generation artifacts for a single generation.
fn build_generation_artifacts_from_links<F>(
&mut self,
generation_artifacts: &mut GenerationArtifacts,
links: &[GenerationLink],
mut build_generation_artifacts: F,
) -> Result<()>
where
F: FnMut(&mut Self, &Generation, &mut GenerationArtifacts) -> Result<()>,
{
let generations = links let generations = links
.iter() .iter()
.filter_map(|link| { .filter_map(|link| {
@ -214,122 +158,151 @@ impl Installer {
} }
for generation in generations { for generation in generations {
build_generation_artifacts(self, &generation, generation_artifacts) // The kernels and initrds are content-addressed.
.context("Failed to build generation artifacts.")?; // Thus, this cannot overwrite files of old generation with different content.
self.install_generation(&generation)
.context("Failed to install generation.")?;
for (name, bootspec) in &generation.spec.bootspec.specialisations { for (name, bootspec) in &generation.spec.bootspec.specialisations {
let specialised_generation = generation.specialise(name, bootspec)?; let specialised_generation = generation.specialise(name, bootspec)?;
self.install_generation(&specialised_generation)
build_generation_artifacts(self, &specialised_generation, generation_artifacts) .context("Failed to install specialisation.")?;
.context("Failed to build generation artifacts for 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(()) Ok(())
} }
/// Build the unsigned generation artifacts for a single generation. /// Install the given `Generation`.
/// ///
/// Stores the mapping from source to destination for the artifacts in the provided /// The kernel and initrd are content-addressed, and the stub name identifies the generation.
/// `GenerationArtifacts`. Does not install any files to the ESP. /// Hence, this function cannot overwrite files of other generations with different contents.
/// /// All installed files are added as garbage collector roots.
/// Because this function already has an complete view of all required paths in the ESP for fn install_generation(&mut self, generation: &Generation) -> Result<()> {
/// this generation, it stores all paths as GC roots. // If the generation is already properly installed, don't overwrite it.
fn build_unsigned_generation_artifacts( if self.register_installed_generation(generation).is_ok() {
&mut self, return Ok(());
generation: &Generation, }
generation_artifacts: &mut GenerationArtifacts,
) -> Result<()> {
let tempdir = &generation_artifacts.tempdir;
let tempdir = TempDir::new().context("Failed to create temporary directory.")?;
let bootspec = &generation.spec.bootspec.bootspec; let bootspec = &generation.spec.bootspec.bootspec;
let esp_gen_paths = EspGenerationPaths::new(&self.esp_paths, generation, self.arch)?; // The kernel is a file in /nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-linux-<version>/.
self.gc_roots.extend(esp_gen_paths.to_iter()); // (On x86, that file is called bzImage, but other architectures may differ.)
let kernel_dirname = bootspec
.kernel
.parent()
.and_then(Path::file_name)
.and_then(OsStr::to_str)
.context("Failed to extract the kernel directory name.")?;
let kernel_version = kernel_dirname
.rsplit('-')
.next()
.context("Failed to extract the kernel version.")?;
let initrd_content = fs::read( // Install the kernel and record its path on the ESP.
bootspec let kernel_target = self
.initrd .install_nixos_ca(&bootspec.kernel, &format!("kernel-{}", kernel_version))
.as_ref() .context("Failed to install the kernel.")?;
.context("Lanzaboote does not support missing initrd yet")?,
)?; // Assemble and install the initrd, and record its path on the ESP.
let initrd_location = tempdir let initrd_location = tempdir
.write_secure_file(initrd_content) .write_secure_file(
.context("Failed to copy initrd to tempfile.")?; 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 { if let Some(initrd_secrets_script) = &bootspec.initrd_secrets {
append_initrd_secrets(initrd_secrets_script, &initrd_location)?; append_initrd_secrets(initrd_secrets_script, &initrd_location)?;
} }
let initrd_target = self
.install_nixos_ca(&initrd_location, &format!("initrd-{}", kernel_version))
.context("Failed to install the initrd.")?;
// The initrd and kernel don't need to be signed. The stub has their hashes embedded and // Assemble, sign and install the Lanzaboote stub.
// will refuse loading on hash mismatches.
//
// The kernel is not signed because systemd-boot could be tricked into loading the signed
// kernel in combination with an malicious unsigned initrd. This could be achieved because
// systemd-boot also honors the type #1 boot loader specification.
generation_artifacts.add_unsigned(&bootspec.kernel, &esp_gen_paths.kernel);
generation_artifacts.add_unsigned(&initrd_location, &esp_gen_paths.initrd);
Ok(())
}
/// Build the signed generation artifacts for a single generation.
///
/// Stores the mapping from source to destination for the artifacts in the provided
/// `GenerationArtifacts`. Does not install any files to the ESP.
///
/// This function expects an already pre-populated `GenerationArtifacts`. It can only be called
/// if ALL unsigned artifacts are already built and stored in `GenerationArtifacts`. More
/// specifically, this function can only be called after `build_unsigned_generation_artifacts`
/// has been executed.
fn build_signed_generation_artifacts(
&mut self,
generation: &Generation,
generation_artifacts: &mut GenerationArtifacts,
) -> Result<()> {
let tempdir = &generation_artifacts.tempdir;
let bootspec = &generation.spec.bootspec.bootspec;
let esp_gen_paths = EspGenerationPaths::new(&self.esp_paths, generation, self.arch)?;
let kernel_cmdline =
assemble_kernel_cmdline(&bootspec.init, bootspec.kernel_params.clone());
let os_release = OsRelease::from_generation(generation) let os_release = OsRelease::from_generation(generation)
.context("Failed to build OsRelease from generation.")?; .context("Failed to build OsRelease from generation.")?;
let os_release_path = tempdir let os_release_path = tempdir
.write_secure_file(os_release.to_string().as_bytes()) .write_secure_file(os_release.to_string().as_bytes())
.context("Failed to write os-release file.")?; .context("Failed to write os-release file.")?;
let kernel_cmdline =
let kernel_path: &Path = generation_artifacts assemble_kernel_cmdline(&bootspec.init, bootspec.kernel_params.clone());
.files
.get(&esp_gen_paths.kernel)
.context("Failed to retrieve kernel path from GenerationArtifacts.")?
.into();
let initrd_path = generation_artifacts
.files
.get(&esp_gen_paths.initrd)
.context("Failed to retrieve initrd path from GenerationArtifacts.")?
.into();
let lanzaboote_image = pe::lanzaboote_image( let lanzaboote_image = pe::lanzaboote_image(
tempdir, &tempdir,
&self.lanzaboote_stub, &self.lanzaboote_stub,
&os_release_path, &os_release_path,
&kernel_cmdline, &kernel_cmdline,
kernel_path, &bootspec.kernel,
initrd_path, &kernel_target,
&esp_gen_paths, &initrd_location,
&initrd_target,
&self.esp_paths.esp, &self.esp_paths.esp,
) )
.context("Failed to assemble lanzaboote image.")?; .context("Failed to assemble lanzaboote image.")?;
let stub_target = self
generation_artifacts.add_signed(&lanzaboote_image, &esp_gen_paths.lanzaboote_image); .esp_paths
.linux
.join(stub_name(generation, &self.key_pair.public_key)?);
self.gc_roots.extend([&stub_target]);
install_signed(&self.key_pair, &lanzaboote_image, &stub_target)
.context("Failed to install the Lanzaboote stub.")?;
Ok(()) Ok(())
} }
/// Register the files of an already installed generation as garbage collection roots.
///
/// An error should not be considered fatal; the generation should be (re-)installed instead.
fn register_installed_generation(&mut self, generation: &Generation) -> Result<()> {
let stub_target = self
.esp_paths
.linux
.join(stub_name(generation, &self.key_pair.public_key)?);
let stub = fs::read(&stub_target)?;
let kernel_path = resolve_efi_path(
&self.esp_paths.esp,
pe::read_section_data(&stub, ".kernelp").context("Missing kernel path.")?,
)?;
let initrd_path = resolve_efi_path(
&self.esp_paths.esp,
pe::read_section_data(&stub, ".initrdp").context("Missing initrd path.")?,
)?;
if !kernel_path.exists() && !initrd_path.exists() {
anyhow::bail!("Missing kernel or initrd.");
}
self.gc_roots
.extend([&stub_target, &kernel_path, &initrd_path]);
Ok(())
}
/// Install a content-addressed file to the `EFI/nixos` directory on the ESP.
///
/// It is automatically added to the garbage collector roots.
/// The full path to the target file is returned.
fn install_nixos_ca(&mut self, from: &Path, label: &str) -> Result<PathBuf> {
let hash = file_hash(from).context("Failed to read the source file.")?;
let to = self.esp_paths.nixos.join(format!(
"{}-{}.efi",
label,
Base32Unpadded::encode_string(&hash)
));
self.gc_roots.extend([&to]);
install(from, &to)?;
Ok(to)
}
/// Install systemd-boot to ESP. /// Install systemd-boot to ESP.
/// ///
/// systemd-boot is only updated when a newer version is available OR when the currently /// systemd-boot is only updated when a newer version is available OR when the currently
@ -380,89 +353,37 @@ impl Installer {
} }
} }
/// A location in the ESP together with information whether the file /// Translate an EFI path to an absolute path on the mounted ESP.
/// needs to be signed. fn resolve_efi_path(esp: &Path, efi_path: &[u8]) -> Result<PathBuf> {
#[derive(Debug, Clone, PartialEq, Eq)] Ok(esp.join(std::str::from_utf8(&efi_path[1..])?.replace('\\', "/")))
enum FileSource {
SignedFile(PathBuf),
UnsignedFile(PathBuf),
} }
impl<'a> From<&'a FileSource> for &'a Path { /// Compute the file name to be used for the stub of a certain generation, signed with the given key.
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 /// The generated name is input-addressed by the toplevel corresponding to the generation and the public part of the signing key.
/// because they are stored in a HashMap using the destination as the key. Thus, there is only fn stub_name(generation: &Generation, public_key: &Path) -> Result<PathBuf> {
/// unique destination paths. let bootspec = &generation.spec.bootspec.bootspec;
/// let stub_inputs = [
/// This enables a two step installation process where all artifacts across all generations are // Generation numbers can be reused if the latest generation was deleted.
/// first collected and then installed. This deduplication in the collection phase reduces the // To detect this, the stub path depends on the actual toplevel used.
/// number of accesesses and writes to the ESP. More importantly, however, in the second step, all ("toplevel", bootspec.toplevel.0.as_os_str().as_bytes()),
/// paths on the ESP are uniquely determined and the images can be generated while being sure that // If the key is rotated, the signed stubs must be re-generated.
/// the hashes embedded in them will point to a valid file on the ESP because the file will not be // So we make their path depend on the public key used for signature.
/// overwritten by a later generation. ("public_key", &fs::read(public_key)?),
struct GenerationArtifacts { ];
/// Temporary directory that stores all temporary files that are created when building the let stub_input_hash = Base32Unpadded::encode_string(&Sha256::digest(
/// GenerationArtifacts. serde_json::to_string(&stub_inputs).unwrap(),
tempdir: TempDir, ));
if let Some(specialisation_name) = generation.is_specialised() {
/// A mapping from target location to source. Ok(PathBuf::from(format!(
files: BTreeMap<PathBuf, FileSource>, "nixos-generation-{}-specialisation-{}-{}.efi",
} generation, specialisation_name, stub_input_hash
)))
impl GenerationArtifacts { } else {
fn new() -> Result<Self> { Ok(PathBuf::from(format!(
Ok(Self { "nixos-generation-{}-{}.efi",
tempdir: TempDir::new().context("Failed to create temporary directory.")?, generation, stub_input_hash
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(())
} }
} }
@ -544,17 +465,29 @@ fn assemble_kernel_cmdline(init: &Path, kernel_params: Vec<String>) -> Vec<Strin
/// Atomically copy a file. /// Atomically copy a file.
/// ///
/// The file is first written to the destination with a `.tmp` suffix and then renamed to its final /// First, the content is written to a temporary file (with a `.tmp` extension).
/// name. This is atomic, because a rename is an atomic operation on POSIX platforms. /// Then, this file is synced, to ensure its data and metadata are fully on disk before continuing.
/// In the last step, the temporary file is renamed to the final destination.
///
/// Due to the deficiencies of FAT32, it is possible for the filesystem to become corrupted after power loss.
/// It is not possible to fully defend against this situation, so this operation is not actually fully atomic.
/// However, in all other cases, the target file is either present with its correct content or not present at all.
fn atomic_copy(from: &Path, to: &Path) -> Result<()> { fn atomic_copy(from: &Path, to: &Path) -> Result<()> {
let to_tmp = to.with_extension(".tmp"); let tmp = to.with_extension(".tmp");
{
fs::copy(from, &to_tmp) let mut from_file =
.with_context(|| format!("Failed to copy from {from:?} to {to_tmp:?}",))?; File::open(from).with_context(|| format!("Failed to read the source file {from:?}"))?;
let mut tmp_file = File::create(&tmp)
fs::rename(&to_tmp, to).with_context(|| { .with_context(|| format!("Failed to create the temporary file {tmp:?}"))?;
format!("Failed to move temporary file {to_tmp:?} to final location {to:?}") std::io::copy(&mut from_file, &mut tmp_file).with_context(|| {
}) format!("Failed to copy from {from:?} to the temporary file {tmp:?}")
})?;
tmp_file
.sync_all()
.with_context(|| format!("Failed to sync the temporary file {tmp:?}"))?;
}
fs::rename(&tmp, to)
.with_context(|| format!("Failed to move temporary file {tmp:?} to target {to:?}"))
} }
/// Set the octal permission bits of the specified file. /// Set the octal permission bits of the specified file.

View File

@ -6,16 +6,18 @@
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::os::unix::prelude::MetadataExt; use std::os::unix::prelude::{MetadataExt, OsStrExt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Output; use std::process::Output;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use assert_cmd::Command; use assert_cmd::Command;
use base32ct::{Base32Unpadded, Encoding};
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng}; use rand::{thread_rng, Rng};
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tempfile::TempDir;
use lanzaboote_tool::architecture::Architecture; use lanzaboote_tool::architecture::Architecture;
use lzbt_systemd::architecture::SystemdArchitectureExt; use lzbt_systemd::architecture::SystemdArchitectureExt;
@ -58,8 +60,9 @@ pub fn setup_generation_link_from_toplevel(
let bootspec = json!({ let bootspec = json!({
"org.nixos.bootspec.v1": { "org.nixos.bootspec.v1": {
"init": format!("init-v{}", version), "init": format!("init-v{}", version),
"initrd": toplevel.join("initrd"), // Normally, these are in the Nix store.
"kernel": toplevel.join("kernel"), "initrd": toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/initrd"),
"kernel": toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/kernel"),
"kernelParams": [ "kernelParams": [
"amd_iommu=on", "amd_iommu=on",
"amd_iommu=pt", "amd_iommu=pt",
@ -96,10 +99,12 @@ pub fn setup_generation_link_from_toplevel(
/// it (and when it goes out of scope). /// it (and when it goes out of scope).
pub fn setup_toplevel(tmpdir: &Path) -> Result<PathBuf> { pub fn setup_toplevel(tmpdir: &Path) -> Result<PathBuf> {
let system = Architecture::from_nixos_system(SYSTEM)?; let system = Architecture::from_nixos_system(SYSTEM)?;
// Generate a random toplevel name so that multiple toplevel paths can live alongside each // Generate a random toplevel name so that multiple toplevel paths can live alongside each
// other in the same directory. // other in the same directory.
let toplevel = tmpdir.join(format!("toplevel-{}", random_string(8))); let toplevel = tmpdir.join(format!("toplevel-{}", random_string(8)));
fs::create_dir(&toplevel)?; let fake_store_path = toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1");
fs::create_dir_all(&fake_store_path)?;
let test_systemd = systemd_location_from_env()?; let test_systemd = systemd_location_from_env()?;
let systemd_stub_filename = system.systemd_stub_filename(); let systemd_stub_filename = system.systemd_stub_filename();
@ -108,8 +113,8 @@ pub fn setup_toplevel(tmpdir: &Path) -> Result<PathBuf> {
systemd_stub_filename = systemd_stub_filename.display() systemd_stub_filename = systemd_stub_filename.display()
); );
let initrd_path = toplevel.join("initrd"); let initrd_path = fake_store_path.join("initrd");
let kernel_path = toplevel.join("kernel"); let kernel_path = fake_store_path.join("kernel");
let nixos_version_path = toplevel.join("nixos-version"); let nixos_version_path = toplevel.join("nixos-version");
let kernel_modules_path = toplevel.join("kernel-modules/lib/modules/6.1.1"); let kernel_modules_path = toplevel.join("kernel-modules/lib/modules/6.1.1");
@ -234,3 +239,23 @@ pub fn verify_signature(path: &Path) -> Result<bool> {
pub fn count_files(path: &Path) -> Result<usize> { pub fn count_files(path: &Path) -> Result<usize> {
Ok(fs::read_dir(path)?.count()) Ok(fs::read_dir(path)?.count())
} }
pub fn image_path(esp: &TempDir, version: u64, toplevel: &Path) -> Result<PathBuf> {
let stub_inputs = [
// Generation numbers can be reused if the latest generation was deleted.
// To detect this, the stub path depends on the actual toplevel used.
("toplevel", toplevel.as_os_str().as_bytes()),
// If the key is rotated, the signed stubs must be re-generated.
// So we make their path depend on the public key used for signature.
(
"public_key",
&std::fs::read("tests/fixtures/uefi-keys/db.pem")?,
),
];
let stub_input_hash = Base32Unpadded::encode_string(&Sha256::digest(
serde_json::to_string(&stub_inputs).unwrap(),
));
Ok(esp.path().join(format!(
"EFI/Linux/nixos-generation-{version}-{stub_input_hash}.efi"
)))
}

View File

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

View File

@ -1,8 +1,6 @@
use std::fs; use anyhow::Result;
use std::path::{Path, PathBuf}; use base32ct::{Base32Unpadded, Encoding};
use tempfile::tempdir;
use anyhow::{Context, Result};
use tempfile::{tempdir, TempDir};
mod common; mod common;
@ -38,16 +36,17 @@ fn do_not_install_duplicates() -> Result<()> {
} }
#[test] #[test]
fn overwrite_unsigned_images() -> Result<()> { fn do_not_overwrite_images() -> Result<()> {
let esp = tempdir()?; let esp = tempdir()?;
let tmpdir = tempdir()?; let tmpdir = tempdir()?;
let profiles = tempdir()?; let profiles = tempdir()?;
let toplevel = common::setup_toplevel(tmpdir.path())?;
let image1 = image_path(&esp, 1); let image1 = common::image_path(&esp, 1, &toplevel)?;
let image2 = image_path(&esp, 2); let image2 = common::image_path(&esp, 2, &toplevel)?;
let generation_link1 = common::setup_generation_link(tmpdir.path(), profiles.path(), 1)?; let generation_link1 = setup_generation_link_from_toplevel(&toplevel, profiles.path(), 1)?;
let generation_link2 = common::setup_generation_link(tmpdir.path(), profiles.path(), 2)?; let generation_link2 = setup_generation_link_from_toplevel(&toplevel, profiles.path(), 2)?;
let generation_links = vec![generation_link1, generation_link2]; let generation_links = vec![generation_link1, generation_link2];
let output1 = common::lanzaboote_install(0, esp.path(), generation_links.clone())?; let output1 = common::lanzaboote_install(0, esp.path(), generation_links.clone())?;
@ -60,14 +59,42 @@ fn overwrite_unsigned_images() -> Result<()> {
let output2 = common::lanzaboote_install(0, esp.path(), generation_links)?; let output2 = common::lanzaboote_install(0, esp.path(), generation_links)?;
assert!(output2.status.success()); assert!(output2.status.success());
assert!(verify_signature(&image1)?); assert!(!verify_signature(&image1)?);
assert!(verify_signature(&image2)?); assert!(verify_signature(&image2)?);
Ok(()) Ok(())
} }
#[test] #[test]
fn overwrite_unsigned_files() -> Result<()> { fn detect_generation_number_reuse() -> Result<()> {
let esp = tempdir()?;
let tmpdir = tempdir()?;
let profiles = tempdir()?;
let toplevel1 = common::setup_toplevel(tmpdir.path())?;
let toplevel2 = common::setup_toplevel(tmpdir.path())?;
let image1 = common::image_path(&esp, 1, &toplevel1)?;
// this deliberately gets the same number!
let image2 = common::image_path(&esp, 1, &toplevel2)?;
let generation_link1 = setup_generation_link_from_toplevel(&toplevel1, profiles.path(), 1)?;
let output1 = common::lanzaboote_install(0, esp.path(), vec![generation_link1])?;
assert!(output1.status.success());
assert!(image1.exists());
assert!(!image2.exists());
std::fs::remove_dir_all(profiles.path().join("system-1-link"))?;
let generation_link2 = setup_generation_link_from_toplevel(&toplevel2, profiles.path(), 1)?;
let output2 = common::lanzaboote_install(0, esp.path(), vec![generation_link2])?;
assert!(output2.status.success());
assert!(!image1.exists());
assert!(image2.exists());
Ok(())
}
#[test]
fn content_addressing_works() -> Result<()> {
let esp = tempdir()?; let esp = tempdir()?;
let tmpdir = tempdir()?; let tmpdir = tempdir()?;
let profiles = tempdir()?; let profiles = tempdir()?;
@ -76,46 +103,21 @@ fn overwrite_unsigned_files() -> Result<()> {
let generation_link = setup_generation_link_from_toplevel(&toplevel, profiles.path(), 1)?; let generation_link = setup_generation_link_from_toplevel(&toplevel, profiles.path(), 1)?;
let generation_links = vec![generation_link]; let generation_links = vec![generation_link];
let kernel_hash_source = hash_file(&toplevel.join("kernel")); let kernel_hash_source =
hash_file(&toplevel.join("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-6.1.1/kernel"));
let nixos_dir = esp.path().join("EFI/nixos");
let kernel_path = nixos_dir.join(nixos_path(toplevel.join("kernel"), "bzImage")?);
fs::create_dir_all(&nixos_dir)?;
fs::write(&kernel_path, b"Existing kernel")?;
let kernel_hash_existing = hash_file(&kernel_path);
let output0 = common::lanzaboote_install(1, esp.path(), generation_links)?; let output0 = common::lanzaboote_install(1, esp.path(), generation_links)?;
assert!(output0.status.success()); assert!(output0.status.success());
let kernel_hash_overwritten = hash_file(&kernel_path); let kernel_path = esp.path().join(format!(
"EFI/nixos/kernel-6.1.1-{}.efi",
Base32Unpadded::encode_string(&kernel_hash_source)
));
// Assert existing kernel was overwritten. // Implicitly assert that the content-addressed file actually exists.
assert_ne!(kernel_hash_existing, kernel_hash_overwritten); let kernel_hash = hash_file(&kernel_path);
// Assert overwritten kernel is the source kernel. // Assert the written kernel is the source kernel.
assert_eq!(kernel_hash_source, kernel_hash_overwritten); assert_eq!(kernel_hash_source, kernel_hash);
Ok(()) Ok(())
} }
fn image_path(esp: &TempDir, version: u64) -> PathBuf {
esp.path()
.join(format!("EFI/Linux/nixos-generation-{version}.efi"))
}
fn nixos_path(path: impl AsRef<Path>, name: &str) -> Result<PathBuf> {
let resolved = path
.as_ref()
.read_link()
.unwrap_or_else(|_| path.as_ref().into());
let parent_final_component = resolved
.parent()
.and_then(|x| x.file_name())
.and_then(|x| x.to_str())
.with_context(|| format!("Failed to extract final component from: {:?}", resolved))?;
let nixos_filename = format!("{}-{}.efi", parent_final_component, name);
Ok(PathBuf::from(nixos_filename))
}

View File

@ -11,18 +11,16 @@ fn generate_expected_os_release() -> Result<()> {
let esp_mountpoint = tempdir()?; let esp_mountpoint = tempdir()?;
let tmpdir = tempdir()?; let tmpdir = tempdir()?;
let profiles = tempdir()?; let profiles = tempdir()?;
let toplevel = common::setup_toplevel(tmpdir.path())?;
let generation_link = common::setup_generation_link(tmpdir.path(), profiles.path(), 1) let generation_link =
.expect("Failed to setup generation link"); common::setup_generation_link_from_toplevel(&toplevel, profiles.path(), 1)
.expect("Failed to setup generation link");
let output0 = common::lanzaboote_install(0, esp_mountpoint.path(), vec![generation_link])?; let output0 = common::lanzaboote_install(0, esp_mountpoint.path(), vec![generation_link])?;
assert!(output0.status.success()); assert!(output0.status.success());
let stub_data = fs::read( let stub_data = fs::read(common::image_path(&esp_mountpoint, 1, &toplevel)?)?;
esp_mountpoint
.path()
.join("EFI/Linux/nixos-generation-1.efi"),
)?;
let os_release_section = pe_section(&stub_data, ".osrel") let os_release_section = pe_section(&stub_data, ".osrel")
.context("Failed to read .osrelease PE section.")? .context("Failed to read .osrelease PE section.")?
.to_owned(); .to_owned();