Skip to main content

bootc_lib/bootc_composefs/
uki_addon.rs

1use std::collections::HashSet;
2use std::fmt;
3
4use anyhow::{Context, Result};
5use bootc_mount::tempmount::TempMount;
6use cap_std_ext::cap_std::fs::Dir;
7use cap_std_ext::dirext::CapStdExtDirExt;
8use fn_error_context::context;
9use ostree_ext::{
10    composefs::fsverity::{FsVerityHashValue, Sha512HashValue},
11    composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT},
12    composefs_oci::linked_erofs_images,
13};
14use serde::Serialize;
15
16use crate::{
17    bootc_composefs::{
18        boot::{BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR, STAGED_GLOBAL_UKI_ADDONS_DIR},
19        status::BootloaderEntry,
20    },
21    composefs_consts::UKI_NAME_PREFIX,
22    spec::BootEntry,
23    store::{BootedComposefs, Storage},
24};
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(tag = "type", rename_all = "lowercase")]
28pub enum UkiAddonType {
29    Scoped { depl_id: String },
30    Global,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct UkiAddonsList {
35    pub name: String,
36    pub addon_type: UkiAddonType,
37    /// Whether this addon is part of a staged deployment
38    pub staged: bool,
39}
40
41impl fmt::Display for UkiAddonsList {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        let type_str = match &self.addon_type {
44            UkiAddonType::Global => "global",
45            UkiAddonType::Scoped { .. } => "scoped",
46        };
47
48        // (label, value) rows; the first is the bulleted header line.
49        let mut rows: Vec<(&str, &str)> = vec![("Addon", self.name.as_str()), ("Type", type_str)];
50
51        if let UkiAddonType::Scoped { depl_id } = &self.addon_type {
52            rows.push(("Deployment", depl_id.as_str()));
53        }
54
55        rows.push(("Staged", if self.staged { "yes" } else { "no" }));
56
57        // Left-align the labels and pad so every value starts in the same column.
58        let max_label = rows
59            .iter()
60            .map(|(label, _)| label.chars().count())
61            .max()
62            .unwrap_or(0);
63
64        // Width of the "<label>:" column, so values line up one space after it.
65        let label_width = max_label + 1;
66        let last = rows.len() - 1;
67
68        for (i, (label, value)) in rows.iter().enumerate() {
69            let label = format!("{label}:");
70
71            if i == last {
72                write!(f, "{label:<label_width$} {value}")?;
73            } else {
74                writeln!(f, "{label:<label_width$} {value}")?;
75            }
76        }
77
78        Ok(())
79    }
80}
81
82fn gather_addons_from_dir(
83    dir: &Dir,
84    addons: &mut Vec<UkiAddonsList>,
85    addon_type: UkiAddonType,
86    staged: bool,
87) -> Result<()> {
88    for ent in dir.entries_utf8()? {
89        let ent = ent?;
90        let filename = ent.file_name()?;
91
92        let Some(addon_name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) else {
93            continue;
94        };
95
96        match addon_type {
97            UkiAddonType::Global => {
98                let Some(addon_name) = addon_name.strip_prefix(UKI_NAME_PREFIX) else {
99                    tracing::info!("Global UKI Addon not managed by bootc found: {addon_name}");
100                    continue;
101                };
102
103                addons.push(UkiAddonsList {
104                    name: addon_name.to_string(),
105                    addon_type: addon_type.clone(),
106                    staged,
107                });
108            }
109            UkiAddonType::Scoped { .. } => {
110                addons.push(UkiAddonsList {
111                    name: addon_name.to_string(),
112                    addon_type: addon_type.clone(),
113                    staged,
114                });
115            }
116        }
117    }
118
119    Ok(())
120}
121
122/// Gathers UKI Addons (Global + Scoped) from the ESP
123#[context("Gathering addons from filesystem")]
124pub fn gather_addons_from_filesystem(
125    boot_dir: &Dir,
126    depl_id: Option<&str>,
127    staged_deployment: &Option<BootEntry>,
128) -> Result<Vec<UkiAddonsList>> {
129    let mut addons_list: Vec<UkiAddonsList> = vec![];
130
131    for dir in [GLOBAL_UKI_ADDONS_DIR, STAGED_GLOBAL_UKI_ADDONS_DIR] {
132        if let Some(global_dir) = boot_dir.open_dir_optional(dir)? {
133            for entry in global_dir.entries_utf8()? {
134                let entry = entry?;
135                let filename = entry.file_name()?;
136
137                if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) {
138                    addons_list.push(UkiAddonsList {
139                        name: name.to_string(),
140                        addon_type: UkiAddonType::Global,
141                        staged: dir == STAGED_GLOBAL_UKI_ADDONS_DIR,
142                    });
143                }
144            }
145        }
146    }
147
148    let Some(efi_linux) = boot_dir.open_dir_optional(EFI_LINUX)? else {
149        return Ok(addons_list);
150    };
151
152    let staged_deployment_id = match staged_deployment {
153        Some(entry) => Some(entry.require_composefs()?.verity.as_str()),
154        None => None,
155    };
156
157    for entry in efi_linux.entries_utf8()? {
158        let entry = entry?;
159
160        if !entry.file_type()?.is_dir() {
161            continue;
162        }
163
164        let dirname = entry.file_name()?;
165
166        // This will usually be the kernel version
167        let Some(..) = dirname.strip_suffix(EFI_ADDON_DIR_EXT) else {
168            continue;
169        };
170
171        let dir = efi_linux
172            .open_dir(&dirname)
173            .with_context(|| format!("Opening {dirname}"))?;
174
175        for addon_ent in dir.entries_utf8()? {
176            let addon_ent = addon_ent?;
177            let filename = addon_ent.file_name()?;
178
179            if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) {
180                addons_list.push(UkiAddonsList {
181                    name: name.to_string(),
182                    addon_type: UkiAddonType::Scoped {
183                        // We can't always have the deployment id with us
184                        depl_id: depl_id.map(|x| x.to_string()).unwrap_or("".into()),
185                    },
186                    staged: staged_deployment_id
187                        .is_some_and(|staged_id| Some(staged_id) == depl_id),
188                });
189            }
190        }
191    }
192
193    Ok(addons_list)
194}
195
196#[context("Listing UKI Addons")]
197pub fn list_installed_uki_addons(
198    storage: &Storage,
199    staged_deployment: &Option<BootEntry>,
200) -> Result<Vec<UkiAddonsList>> {
201    let mut addons = vec![];
202
203    let Ok(boot_mount) = storage.require_boot_mount() else {
204        return Ok(addons);
205    };
206
207    if let Some(global_dir) = boot_mount.fd.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? {
208        gather_addons_from_dir(&global_dir, &mut addons, UkiAddonType::Global, false)
209            .context("Gathering global addons")?;
210    };
211
212    if let Some(global_dir) = boot_mount
213        .fd
214        .open_dir_optional(STAGED_GLOBAL_UKI_ADDONS_DIR)?
215    {
216        gather_addons_from_dir(&global_dir, &mut addons, UkiAddonType::Global, true)
217            .context("Gathering staged global addons")?;
218    };
219
220    let Some(bootc_uki_dir) = boot_mount
221        .fd
222        .open_dir_optional(BOOTC_UKI_DIR)
223        .context("Opening UKI dir")?
224    else {
225        return Ok(addons);
226    };
227
228    let staged_deployment_id = match staged_deployment {
229        Some(ent) => Some(ent.require_composefs()?.verity.as_str()),
230        None => None,
231    };
232
233    for ent in bootc_uki_dir
234        .entries_utf8()
235        .context("Reading UKI dir entries")?
236    {
237        let ent = ent?;
238        let filename = ent.file_name()?;
239
240        if !ent.file_type()?.is_dir() {
241            continue;
242        }
243
244        let Some(dir_name) = filename.strip_suffix(EFI_ADDON_DIR_EXT) else {
245            continue;
246        };
247
248        let depl_id = dir_name.strip_prefix(UKI_NAME_PREFIX).unwrap_or(dir_name);
249
250        let dir = boot_mount
251            .fd
252            .open_dir(format!("{BOOTC_UKI_DIR}/{filename}"))
253            .with_context(|| format!("Opening {filename}"))?;
254
255        gather_addons_from_dir(
256            &dir,
257            &mut addons,
258            UkiAddonType::Scoped {
259                depl_id: depl_id.to_string(),
260            },
261            staged_deployment_id.is_some_and(|verity| verity == depl_id),
262        )?;
263    }
264
265    Ok(addons)
266}
267
268/// Go through all the EROFS images and get all the referenced UKI Addons
269///
270/// Returns a list of tuple of (EROFS verity, List of referenced addons)
271#[context("Getting all referenced UKI Addons")]
272pub fn list_referenced_uki_addons(
273    booted_cfs: &BootedComposefs,
274    bootloader_entries: &Vec<BootloaderEntry>,
275    staged_deployment: &Option<BootEntry>,
276) -> Result<Vec<(String, Vec<UkiAddonsList>)>> {
277    let mut all_referenced_addons: Vec<(String, Vec<UkiAddonsList>)> = vec![];
278
279    for entry in bootloader_entries {
280        let verity = Sha512HashValue::from_hex(&entry.fsverity);
281
282        let verity = match verity {
283            Ok(v) => v,
284            Err(e) => {
285                tracing::warn!(
286                    "Invalid fsverity found in bootloader entry {}: {e:?}",
287                    entry.fsverity
288                );
289                continue;
290            }
291        };
292
293        let linked_erofs = match linked_erofs_images(&booted_cfs.repo, &verity) {
294            Ok(v) => v,
295            // Skip entries with no linked EROFS image, but propagate any other error
296            // TODO: Update with proper error downcasted match once we have
297            // https://github.com/composefs/composefs-rs/pull/400 released
298            Err(e)
299                if e.chain()
300                    .any(|c| c.to_string().contains("No EROFS image found")) =>
301            {
302                tracing::debug!("No EROFS image found for {}", entry.fsverity);
303                continue;
304            }
305            Err(e) => return Err(e),
306        };
307
308        let Some(non_bootable) = linked_erofs.iter().find(|e| !e.bootable) else {
309            tracing::debug!("No non-bootable EROFS found for {}", entry.fsverity);
310            continue;
311        };
312
313        let composefs_mnt_fd = booted_cfs
314            .repo
315            .mount(&non_bootable.id.to_hex())
316            .context("Failed to mount composefs image")?;
317
318        let composefs = TempMount::mount_fd(composefs_mnt_fd)
319            .context("Attaching composefs image to temporary directory")?;
320
321        let cfs_boot_dir = composefs
322            .fd
323            .open_dir("boot")
324            .context("Opening boot directory in composefs image")?;
325
326        let addons_list =
327            gather_addons_from_filesystem(&cfs_boot_dir, Some(&entry.fsverity), staged_deployment)?;
328
329        // We work with the bootable fsverity everywhere
330        all_referenced_addons.push((entry.fsverity.clone(), addons_list));
331    }
332
333    Ok(all_referenced_addons)
334}
335
336/// Installed global UKI addons that will be garbage-collected on the next
337/// update/switch.
338///
339/// A global addon is removed by GC once no deployment's image references it
340/// This returns the installed global addons that survive the current update's
341/// GC but are headed for removal on the next one unless they are re-referenced
342///
343/// We do this to show a notice/warning to the user that their global addon might
344/// be removed unless re-referenced by another image
345#[context("Finding global UKI addons pending removal")]
346pub(crate) fn global_addons_pending_removal(
347    storage: &Storage,
348    booted_cfs: &BootedComposefs,
349    staged_verity: &str,
350    pinned_verities: &[String],
351    bootloader_entries: &Vec<BootloaderEntry>,
352) -> Result<Vec<String>> {
353    // Global addons currently installed on the ESP.
354    let installed: Vec<String> = list_installed_uki_addons(storage, &None)?
355        .into_iter()
356        .filter(|addon| addon.addon_type == UkiAddonType::Global)
357        .map(|addon| addon.name)
358        .collect();
359
360    if installed.is_empty() {
361        return Ok(vec![]);
362    }
363
364    // Per-deployment addon references, keyed by EROFS verity.
365    let referenced = list_referenced_uki_addons(booted_cfs, bootloader_entries, &None)?;
366
367    // The set of global addon names referenced by any of the given deployments.
368    let global_refs_for = |verities: &HashSet<&str>| -> HashSet<String> {
369        referenced
370            .iter()
371            .filter(|(verity, _)| verities.contains(verity.as_str()))
372            .flat_map(|(_, addons)| addons.iter())
373            .filter(|addon| addon.addon_type == UkiAddonType::Global)
374            .map(|addon| addon.name.clone())
375            .collect()
376    };
377
378    let ref_booted = global_refs_for(&HashSet::from([&*booted_cfs.cmdline.digest]));
379    let ref_staged = global_refs_for(&HashSet::from([staged_verity]));
380    let ref_pinned = global_refs_for(&pinned_verities.iter().map(|s| s.as_str()).collect());
381
382    let pending = installed
383        .into_iter()
384        .filter(|name| {
385            ref_booted.contains(name) && !ref_staged.contains(name) && !ref_pinned.contains(name)
386        })
387        .collect();
388
389    Ok(pending)
390}