bootc_lib/bootc_composefs/
uki_addon.rs1use 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 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 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 let max_label = rows
59 .iter()
60 .map(|(label, _)| label.chars().count())
61 .max()
62 .unwrap_or(0);
63
64 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#[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 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 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#[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 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 all_referenced_addons.push((entry.fsverity.clone(), addons_list));
331 }
332
333 Ok(all_referenced_addons)
334}
335
336#[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 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 let referenced = list_referenced_uki_addons(booted_cfs, bootloader_entries, &None)?;
366
367 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}