Skip to main content

bootc_lib/bootc_composefs/
uki_addons_cli.rs

1use std::io::{Seek, SeekFrom};
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use bootc_mount::tempmount::TempMount;
6use camino::Utf8PathBuf;
7use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
8use fn_error_context::context;
9use ostree_ext::{
10    composefs::fsverity::{FsVerityHashValue, Sha512HashValue},
11    composefs_boot::{
12        bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT},
13        cmdline::ComposefsCmdline as ComposefsBootCmdline,
14        uki,
15    },
16    composefs_oci::linked_erofs_images,
17};
18
19use crate::bootc_composefs::boot::STAGED_GLOBAL_UKI_ADDONS_DIR;
20use crate::spec::Host;
21use crate::{
22    bootc_composefs::{
23        boot::{
24            BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR, get_global_uki_addon_name,
25            get_scoped_uki_addon_name, get_uki_addon_dir_name,
26        },
27        status::list_bootloader_entries,
28        uki_addon::{UkiAddonType, list_installed_uki_addons, list_referenced_uki_addons},
29    },
30    cli::{UkiAddonCliOpts, UkiAddonScope},
31    store::{BootedComposefs, Storage},
32};
33
34#[context("Verifying {addon_type:?} {addon_name} addon exists")]
35fn verify_addon_exists(
36    boot_dir: &Dir,
37    addon_name: &str,
38    addon_type: UkiAddonScope,
39) -> Result<Utf8PathBuf> {
40    let mut path = Utf8PathBuf::from("boot");
41
42    match addon_type {
43        UkiAddonScope::Global => {
44            let addons_dir = boot_dir.open_dir(GLOBAL_UKI_ADDONS_DIR)?;
45
46            for entry in addons_dir.entries_utf8()? {
47                let entry = entry?;
48                let filename = entry.file_name()?;
49
50                if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) {
51                    if name == addon_name {
52                        return Ok(path.join(GLOBAL_UKI_ADDONS_DIR).join(filename));
53                    }
54                };
55            }
56        }
57
58        UkiAddonScope::Scoped => {
59            path = path.join(EFI_LINUX);
60
61            for entry in boot_dir
62                .open_dir(EFI_LINUX)
63                .context("Opening EFI/Linux")?
64                .entries_utf8()?
65            {
66                let entry = entry?;
67
68                if !entry.file_type()?.is_dir() {
69                    continue;
70                }
71
72                let dirname = entry.file_name()?;
73
74                if !dirname.ends_with(EFI_ADDON_DIR_EXT) {
75                    continue;
76                }
77
78                path.push(&dirname);
79
80                for addon_ent in entry.open_dir()?.entries()? {
81                    let addon_ent = addon_ent?;
82                    let filename = addon_ent.file_name()?;
83
84                    if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) {
85                        if name == addon_name {
86                            return Ok(path.join(filename));
87                        }
88                    };
89                }
90
91                path.pop();
92            }
93        }
94    };
95
96    anyhow::bail!("{addon_name} not found");
97}
98
99pub(crate) fn handle_addon_cli_cmd(
100    storage: &Storage,
101    booted_cfs: &BootedComposefs,
102    host: &Host,
103    opts: &UkiAddonCliOpts,
104) -> Result<()> {
105    let Ok(boot_mount) = storage.require_boot_mount() else {
106        anyhow::bail!("ESP not found");
107    };
108
109    match opts {
110        UkiAddonCliOpts::List { json, verbose } => {
111            let mut addons = list_installed_uki_addons(storage, &host.status.staged)?;
112
113            if !verbose {
114                addons = addons
115                    .into_iter()
116                    .filter(|addon| !addon.staged)
117                    .collect::<Vec<_>>();
118            }
119
120            if *json {
121                return serde_json::to_writer(std::io::stdout(), &addons)
122                    .context("Writing JSON output");
123            }
124
125            if addons.is_empty() {
126                println!("No UKI addons installed");
127                return Ok(());
128            }
129
130            for (i, addon) in addons.iter().enumerate() {
131                // Separate addon blocks with a blank line.
132                if i > 0 {
133                    println!();
134                }
135                println!("{addon}");
136            }
137        }
138        UkiAddonCliOpts::Remove {
139            name: addon_name,
140            addon_type,
141        } => {
142            let addons = list_installed_uki_addons(storage, &host.status.staged)?;
143
144            let is_staged = host.status.staged.is_some();
145
146            match addon_type {
147                UkiAddonScope::Scoped => {
148                    // If we have a staged deployment, we'll operate on that
149                    let depl_id = match &host.status.staged {
150                        Some(staged) => staged.require_composefs()?.verity.as_str(),
151                        None => &*booted_cfs.cmdline.digest,
152                    };
153
154                    let found = addons.iter().any(|addon| {
155                        matches!(
156                            &addon.addon_type,
157                            UkiAddonType::Scoped { depl_id: id } if id == depl_id
158                        ) && addon.name == *addon_name
159                            && addon.staged == is_staged
160                    });
161
162                    if !found {
163                        anyhow::bail!(
164                            "No addon found with the name {addon_name} for deployment {depl_id}"
165                        );
166                    }
167
168                    let addon_path = Path::new(BOOTC_UKI_DIR)
169                        .join(get_uki_addon_dir_name(depl_id))
170                        .join(get_scoped_uki_addon_name(addon_name));
171
172                    // Absolutely make sure the addon doesn't contain `composefs=` cmdline
173                    // if it does, we can't remove it
174                    let mut addon_file = boot_mount
175                        .fd
176                        .open(&addon_path)
177                        .with_context(|| format!("Opening {}", addon_path.display()))?;
178
179                    match uki::get_cmdline_buffered(&mut addon_file) {
180                        Ok(cmdline_str) => {
181                            let cfs_cmdline_info =
182                                ComposefsBootCmdline::<Sha512HashValue>::from_cmdline(&cmdline_str)
183                                    .context("Parsing composefs=")?;
184
185                            if let Some(cmdline) = cfs_cmdline_info {
186                                anyhow::bail!(
187                                    "Composefs commandline {cmdline:?} found in addon {addon_name}, cannot remove"
188                                );
189                            };
190                        }
191                        Err(uki::UkiError::MissingSection(..)) => {
192                            // All good, no cmdline section in this addon
193                        }
194                        Err(e) => Err(e).context("Reading cmdline section from addon")?,
195                    };
196
197                    boot_mount
198                        .fd
199                        .remove_file(&addon_path)
200                        .with_context(|| format!("Failed to remove addon {addon_name}"))?;
201
202                    println!("Removed addon {addon_name}");
203
204                    let addons_dir = addon_path
205                        .parent()
206                        .expect("Expected addon path to have a parent");
207
208                    let num_ents = boot_mount
209                        .fd
210                        .open_dir(addons_dir)
211                        .context("Opening addons dir")?
212                        .entries()
213                        .context("Getting addons dir entries")?
214                        .count();
215
216                    // Remove directory if empty
217                    if num_ents == 0 {
218                        boot_mount.fd.remove_dir(&addons_dir).with_context(|| {
219                            format!("Removing addons dir: {}", addons_dir.display())
220                        })?;
221
222                        println!("Removed empty directory {}", addons_dir.display());
223                    }
224                }
225
226                UkiAddonScope::Global => {
227                    let found = addons.iter().any(|addon| {
228                        addon.addon_type == UkiAddonType::Global
229                            && addon.name == *addon_name
230                            && addon.staged == is_staged
231                    });
232
233                    if !found {
234                        anyhow::bail!("No Global addon found with the name {addon_name}");
235                    }
236
237                    let full_addon_name = get_global_uki_addon_name(addon_name);
238
239                    tracing::debug!("Removing Global UKI Addon {full_addon_name}");
240
241                    let global_addons_dir = if is_staged {
242                        STAGED_GLOBAL_UKI_ADDONS_DIR
243                    } else {
244                        GLOBAL_UKI_ADDONS_DIR
245                    };
246
247                    boot_mount
248                        .fd
249                        .remove_file(format!("{global_addons_dir}/{full_addon_name}"))
250                        .with_context(|| format!("Removing global addon {full_addon_name}"))?;
251
252                    println!("Removed Global Addon {addon_name}");
253
254                    let num_ents = boot_mount
255                        .fd
256                        .open_dir(global_addons_dir)
257                        .context("Opening global addons dir")?
258                        .entries()
259                        .context("Getting global addons dir entries")?
260                        .count();
261
262                    // Remove the directory if it's now empty.
263                    if num_ents == 0 {
264                        boot_mount
265                            .fd
266                            .remove_dir(global_addons_dir)
267                            .context("Removing global addons dir")?;
268
269                        println!("Removed empty directory {global_addons_dir}");
270
271                        // The staged global addon set is a transient overlay that
272                        // finalize exchanges with the live set on the next boot. An
273                        // empty overlay is collapsed rather than applied, so removing
274                        // the last staged global addon leaves the currently active
275                        // global addons in effect after reboot.
276                        if is_staged {
277                            println!(
278                                "Note: '{addon_name}' was the last staged global addon. \
279                                 An empty staged global addon set is not applied on boot, so \
280                                 the currently active global addons remain in effect after \
281                                 reboot. To drop all global addons, remove them while no \
282                                 deployment is staged."
283                            );
284                        }
285                    }
286                }
287            }
288        }
289
290        UkiAddonCliOpts::Add {
291            name: addon_name,
292            addon_type,
293        } => {
294            // If we have a staged deployment, we'll operate on that
295            let deployment_id = match &host.status.staged {
296                Some(staged) => staged.require_composefs()?.verity.as_str(),
297                None => &*booted_cfs.cmdline.digest,
298            };
299
300            let is_staged = host.status.staged.is_some();
301
302            // This should never fail
303            // This is either the staged/booted digest
304            let deployment_digest = Sha512HashValue::from_hex(deployment_id.as_bytes())
305                .with_context(|| {
306                    format!("Composefs deployment has bad FSVerity: {deployment_id}")
307                })?;
308
309            let addons = list_installed_uki_addons(storage, &host.status.staged)?;
310
311            let already_present = addons.iter().any(|addon| match addon_type {
312                UkiAddonScope::Global => {
313                    addon.addon_type == UkiAddonType::Global
314                        && addon.name == *addon_name
315                        && addon.staged == is_staged
316                }
317                UkiAddonScope::Scoped => {
318                    matches!(
319                        &addon.addon_type,
320                        UkiAddonType::Scoped { depl_id: id } if *id == deployment_digest.to_hex()
321                    ) && addon.name == *addon_name
322                        && addon.staged == is_staged
323                }
324            });
325
326            if already_present {
327                println!("Addon {addon_name} is already present. Nothing to do.");
328                return Ok(());
329            }
330
331            let linked_images = linked_erofs_images(&booted_cfs.repo, &deployment_digest)
332                .context("Finding linked EROFS for booted deployment")?;
333
334            let Some(non_bootable_img) = linked_images.iter().find(|img| !img.bootable) else {
335                anyhow::bail!("No non-bootable image found. Cannot gather UKI Addons");
336            };
337
338            tracing::debug!("non_bootable_img: {non_bootable_img:#?}");
339
340            // Now we mount the img, and copy from /boot
341            let composefs_mnt_fd = booted_cfs
342                .repo
343                .mount(&non_bootable_img.id.to_hex())
344                .context("Failed to mount composefs image")?;
345
346            let composefs = TempMount::mount_fd(composefs_mnt_fd)
347                .context("Attaching composefs image to temporary directory")?;
348
349            let cfs_boot_dir = composefs
350                .fd
351                .open_dir("boot")
352                .context("Opening boot directory in composefs image")?;
353
354            // Make sure the addon actually exists in the image
355            // before creating directories
356            let addon_path = verify_addon_exists(&cfs_boot_dir, addon_name, *addon_type)?;
357
358            // Make sure this addon doesn't contain a composefs= or composefs.digest= cmdline
359            let mut addon_file = composefs
360                .fd
361                .open(&addon_path)
362                .with_context(|| format!("Opening UKI Addon at {addon_path}"))?;
363
364            // UKI Addon might not even have a cmdline
365            let cmdline = uki::get_cmdline_buffered(&mut addon_file);
366
367            match cmdline {
368                Ok(ref cmdline_str) => {
369                    let cfs_cmdline_info =
370                        ComposefsBootCmdline::<Sha512HashValue>::from_cmdline(cmdline_str)
371                            .context("Parsing composefs=")?;
372
373                    if let Some(cfs_cmdline) = cfs_cmdline_info {
374                        anyhow::bail!(
375                            "composefs cmdline {cfs_cmdline:?} found in UKI Addon {addon_name}. Refusing to add"
376                        );
377                    }
378                }
379                Err(uki::UkiError::MissingSection(..)) => {
380                    // This is fine, no cmdline section
381                    // so it's just a no op
382                }
383                Err(e) => {
384                    return Err(e).context("Getting UKI cmdline");
385                }
386            };
387
388            let (dest_dir, dest_name) = match addon_type {
389                UkiAddonScope::Global => {
390                    let global_addons_dir = if is_staged {
391                        STAGED_GLOBAL_UKI_ADDONS_DIR
392                    } else {
393                        GLOBAL_UKI_ADDONS_DIR
394                    };
395
396                    boot_mount
397                        .fd
398                        .create_dir_all(global_addons_dir)
399                        .context("Creating global addons directory")?;
400
401                    let global_addons_dir = boot_mount
402                        .fd
403                        .open_dir(global_addons_dir)
404                        .context("Opening global addons dir")?;
405
406                    (global_addons_dir, get_global_uki_addon_name(addon_name))
407                }
408                UkiAddonScope::Scoped => {
409                    let dir_path = Path::new(BOOTC_UKI_DIR)
410                        .join(get_uki_addon_dir_name(&deployment_digest.to_hex()));
411
412                    boot_mount
413                        .fd
414                        .create_dir_all(&dir_path)
415                        .context("Creating addons directory")?;
416
417                    let to_dir = boot_mount
418                        .fd
419                        .open_dir(&dir_path)
420                        .context("Opening addons directory")?;
421
422                    (to_dir, get_scoped_uki_addon_name(addon_name))
423                }
424            };
425
426            addon_file
427                .seek(SeekFrom::Start(0))
428                .context("Seeking to start of addon")?;
429
430            dest_dir
431                .atomic_replace_with(&dest_name, |writer| std::io::copy(&mut addon_file, writer))
432                .with_context(|| format!("Writing addon {dest_name}"))?;
433
434            rustix::fs::fsync(
435                dest_dir
436                    .reopen_as_ownedfd()
437                    .context("Reopening as owned fd")?,
438            )
439            .context("fsync")?;
440
441            match addon_type {
442                UkiAddonScope::Global => println!("Global UKI Addon {addon_name} added"),
443                UkiAddonScope::Scoped => {
444                    println!(
445                        "UKI Addon {addon_name} added to {} deployment",
446                        if host.status.staged.is_some() {
447                            "staged"
448                        } else {
449                            "booted"
450                        }
451                    )
452                }
453            }
454        }
455        UkiAddonCliOpts::ListReferenced { json } => {
456            let referenced = list_referenced_uki_addons(
457                booted_cfs,
458                &list_bootloader_entries(storage)?,
459                &host.status.staged,
460            )?;
461
462            if *json {
463                return serde_json::to_writer(std::io::stdout(), &referenced)
464                    .context("Writing JSON output");
465            }
466
467            if referenced.is_empty() {
468                println!("No referenced UKI addons found");
469                return Ok(());
470            }
471
472            for (verity, addons) in &referenced {
473                println!("Deployment {verity}:");
474                if addons.is_empty() {
475                    println!("  (none)");
476                } else {
477                    for addon in addons {
478                        println!("  {addon}");
479                    }
480                }
481            }
482        }
483    }
484
485    Ok(())
486}