bootc_lib/bootc_composefs/
delete.rs

1use std::{collections::HashSet, io::Write, path::Path};
2
3use anyhow::{Context, Result};
4use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
5use composefs::fsverity::Sha512HashValue;
6use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT};
7
8use crate::{
9    bootc_composefs::{
10        boot::{BootType, SYSTEMD_UKI_DIR, find_vmlinuz_initrd_duplicates, get_efi_uuid_source},
11        gc::composefs_gc,
12        repo::open_composefs_repo,
13        rollback::{composefs_rollback, rename_exchange_user_cfg},
14        status::{get_composefs_status, get_sorted_grub_uki_boot_entries},
15    },
16    composefs_consts::{
17        COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE,
18        TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED,
19    },
20    parsers::bls_config::{BLSConfigType, parse_bls_config},
21    spec::{BootEntry, Bootloader, DeploymentEntry},
22    status::Slot,
23    store::{BootedComposefs, Storage},
24};
25
26#[fn_error_context::context("Deleting Type1 Entry {}", depl.deployment.verity)]
27fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: bool) -> Result<()> {
28    let entries_dir_path = if deleting_staged {
29        TYPE1_ENT_PATH_STAGED
30    } else {
31        TYPE1_ENT_PATH
32    };
33
34    let entries_dir = boot_dir
35        .open_dir(entries_dir_path)
36        .context("Opening entries dir")?;
37
38    // We reuse kernel + initrd if they're the same for two deployments
39    // We don't want to delete the (being deleted) deployment's kernel + initrd
40    // if it's in use by any other deployment
41    let should_del_kernel = match depl.deployment.boot_digest.as_ref() {
42        Some(digest) => find_vmlinuz_initrd_duplicates(digest)?
43            .is_some_and(|vec| vec.iter().any(|digest| *digest != depl.deployment.verity)),
44        None => false,
45    };
46
47    for entry in entries_dir.entries_utf8()? {
48        let entry = entry?;
49        let file_name = entry.file_name()?;
50
51        if !file_name.ends_with(".conf") {
52            // We don't put any non .conf file in the entries dir
53            // This is here just for sanity
54            tracing::debug!("Found non .conf file '{file_name}' in entries dir");
55            continue;
56        }
57
58        let cfg = entries_dir
59            .read_to_string(&file_name)
60            .with_context(|| format!("Reading {file_name}"))?;
61
62        let bls_config = parse_bls_config(&cfg)?;
63
64        match &bls_config.cfg_type {
65            BLSConfigType::EFI { efi } => {
66                if !efi.as_str().contains(&depl.deployment.verity) {
67                    continue;
68                }
69
70                // Boot dir in case of EFI will be the ESP
71                tracing::debug!("Deleting EFI .conf file: {}", file_name);
72                entry.remove_file().context("Removing .conf file")?;
73                delete_uki(&depl.deployment.verity, boot_dir)?;
74
75                break;
76            }
77
78            BLSConfigType::NonEFI { options, .. } => {
79                let options = options
80                    .as_ref()
81                    .ok_or(anyhow::anyhow!("options not found in BLS config file"))?;
82
83                if !options.contains(&depl.deployment.verity) {
84                    continue;
85                }
86
87                tracing::debug!("Deleting non-EFI .conf file: {}", file_name);
88                entry.remove_file().context("Removing .conf file")?;
89
90                if should_del_kernel {
91                    delete_kernel_initrd(&bls_config.cfg_type, boot_dir)?;
92                }
93
94                break;
95            }
96
97            BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
98        }
99    }
100
101    if deleting_staged {
102        tracing::debug!(
103            "Deleting staged entries directory: {}",
104            TYPE1_ENT_PATH_STAGED
105        );
106        boot_dir
107            .remove_dir_all(TYPE1_ENT_PATH_STAGED)
108            .context("Removing staged entries dir")?;
109    }
110
111    Ok(())
112}
113
114#[fn_error_context::context("Deleting kernel and initrd")]
115fn delete_kernel_initrd(bls_config: &BLSConfigType, boot_dir: &Dir) -> Result<()> {
116    let BLSConfigType::NonEFI { linux, initrd, .. } = bls_config else {
117        anyhow::bail!("Found EFI config")
118    };
119
120    // "linux" and "initrd" are relative to the boot_dir in our config files
121    tracing::debug!("Deleting kernel: {:?}", linux);
122    boot_dir
123        .remove_file(linux)
124        .with_context(|| format!("Removing {linux:?}"))?;
125
126    for ird in initrd {
127        tracing::debug!("Deleting initrd: {:?}", ird);
128        boot_dir
129            .remove_file(ird)
130            .with_context(|| format!("Removing {ird:?}"))?;
131    }
132
133    // Remove the directory if it's empty
134    //
135    // This shouldn't ever error as we'll never have these in root
136    let dir = linux
137        .parent()
138        .ok_or_else(|| anyhow::anyhow!("Bad path for vmlinuz {linux}"))?;
139
140    let kernel_parent_dir = boot_dir.open_dir(&dir)?;
141
142    if kernel_parent_dir.entries().iter().len() == 0 {
143        // We don't have anything other than kernel and initrd in this directory for now
144        // So this directory should *always* be empty, for now at least
145        tracing::debug!("Deleting empty kernel directory: {:?}", dir);
146        kernel_parent_dir.remove_open_dir()?;
147    };
148
149    Ok(())
150}
151
152/// Deletes the UKI `uki_id` and any addons specific to it
153#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")]
154fn delete_uki(uki_id: &str, esp_mnt: &Dir) -> Result<()> {
155    // TODO: We don't delete global addons here
156    let ukis = esp_mnt.open_dir(SYSTEMD_UKI_DIR)?;
157
158    for entry in ukis.entries_utf8()? {
159        let entry = entry?;
160        let entry_name = entry.file_name()?;
161
162        // The actual UKI PE binary
163        if entry_name == format!("{}{}", uki_id, EFI_EXT) {
164            tracing::debug!("Deleting UKI: {}", entry_name);
165            entry.remove_file().context("Deleting UKI")?;
166        } else if entry_name == format!("{}{}", uki_id, EFI_ADDON_DIR_EXT) {
167            // Addons dir
168            tracing::debug!("Deleting UKI addons directory: {}", entry_name);
169            ukis.remove_dir_all(entry_name)
170                .context("Deleting UKI addons dir")?;
171        }
172    }
173
174    Ok(())
175}
176
177#[fn_error_context::context("Removing Grub Menuentry")]
178fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> Result<()> {
179    let grub_dir = boot_dir.open_dir("grub2").context("Opening grub2")?;
180
181    if deleting_staged {
182        tracing::debug!("Deleting staged grub menuentry file: {}", USER_CFG_STAGED);
183        return grub_dir
184            .remove_file(USER_CFG_STAGED)
185            .context("Deleting staged Menuentry");
186    }
187
188    let mut string = String::new();
189    let menuentries = get_sorted_grub_uki_boot_entries(boot_dir, &mut string)?;
190
191    grub_dir
192        .atomic_replace_with(USER_CFG_STAGED, move |f| -> std::io::Result<_> {
193            f.write_all(get_efi_uuid_source().as_bytes())?;
194
195            for entry in menuentries {
196                if entry.body.chainloader.contains(id) {
197                    continue;
198                }
199
200                f.write_all(entry.to_string().as_bytes())?;
201            }
202
203            Ok(())
204        })
205        .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?;
206
207    rustix::fs::fsync(grub_dir.reopen_as_ownedfd().context("Reopening")?).context("fsync")?;
208
209    rename_exchange_user_cfg(&grub_dir)
210}
211
212#[fn_error_context::context("Deleting boot entries for deployment {}", deployment.deployment.verity)]
213fn delete_depl_boot_entries(
214    deployment: &DeploymentEntry,
215    storage: &Storage,
216    deleting_staged: bool,
217) -> Result<()> {
218    let boot_dir = storage.require_boot_dir()?;
219
220    match deployment.deployment.bootloader {
221        Bootloader::Grub => match deployment.deployment.boot_type {
222            BootType::Bls => delete_type1_entry(deployment, boot_dir, deleting_staged),
223
224            BootType::Uki => {
225                let esp = storage
226                    .esp
227                    .as_ref()
228                    .ok_or_else(|| anyhow::anyhow!("ESP not found"))?;
229
230                remove_grub_menucfg_entry(
231                    &deployment.deployment.verity,
232                    boot_dir,
233                    deleting_staged,
234                )?;
235
236                delete_uki(&deployment.deployment.verity, &esp.fd)
237            }
238        },
239
240        Bootloader::Systemd => {
241            // For Systemd UKI as well, we use .conf files
242            delete_type1_entry(deployment, boot_dir, deleting_staged)
243        }
244    }
245}
246
247#[fn_error_context::context("Getting image objects")]
248pub(crate) fn get_image_objects(sysroot: &Dir) -> Result<HashSet<Sha512HashValue>> {
249    let repo = open_composefs_repo(&sysroot)?;
250
251    let images_dir = sysroot
252        .open_dir("composefs/images")
253        .context("Opening images dir")?;
254
255    let image_entries = images_dir
256        .entries_utf8()
257        .context("Reading entries in images dir")?;
258
259    let mut object_refs = HashSet::new();
260
261    for image in image_entries {
262        let image = image?;
263
264        let img_name = image.file_name().context("Getting image name")?;
265
266        let objects = repo
267            .objects_for_image(&img_name)
268            .with_context(|| format!("Getting objects for image {img_name}"))?;
269
270        object_refs.extend(objects);
271    }
272
273    Ok(object_refs)
274}
275
276#[fn_error_context::context("Deleting image for deployment {}", deployment_id)]
277pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str) -> Result<()> {
278    let img_path = Path::new("composefs").join("images").join(deployment_id);
279
280    tracing::debug!("Deleting EROFS image: {:?}", img_path);
281    sysroot
282        .remove_file(&img_path)
283        .context("Deleting EROFS image")
284}
285
286#[fn_error_context::context("Deleting state directory for deployment {}", deployment_id)]
287pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str) -> Result<()> {
288    let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id);
289
290    tracing::debug!("Deleting state directory: {:?}", state_dir);
291    sysroot
292        .remove_dir_all(&state_dir)
293        .with_context(|| format!("Removing dir {state_dir:?}"))
294}
295
296#[fn_error_context::context("Deleting staged deployment")]
297pub(crate) fn delete_staged(staged: &Option<BootEntry>) -> Result<()> {
298    if staged.is_none() {
299        tracing::debug!("No staged deployment");
300        return Ok(());
301    };
302
303    let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME);
304    tracing::debug!("Deleting staged deployment file: {file:?}");
305    std::fs::remove_file(file).context("Removing staged file")?;
306
307    Ok(())
308}
309
310#[fn_error_context::context("Deleting composefs deployment {}", deployment_id)]
311pub(crate) async fn delete_composefs_deployment(
312    deployment_id: &str,
313    storage: &Storage,
314    booted_cfs: &BootedComposefs,
315) -> Result<()> {
316    let host = get_composefs_status(storage, booted_cfs).await?;
317
318    let booted = host.require_composefs_booted()?;
319
320    if deployment_id == &booted.verity {
321        anyhow::bail!("Cannot delete currently booted deployment");
322    }
323
324    let all_depls = host.all_composefs_deployments()?;
325
326    let depl_to_del = all_depls
327        .iter()
328        .find(|d| d.deployment.verity == deployment_id);
329
330    let Some(depl_to_del) = depl_to_del else {
331        anyhow::bail!("Deployment {deployment_id} not found");
332    };
333
334    let deleting_staged = host
335        .status
336        .staged
337        .as_ref()
338        .and_then(|s| s.composefs.as_ref())
339        .map_or(false, |cfs| cfs.verity == deployment_id);
340
341    // Unqueue rollback. This makes it easier to delete boot entries later on
342    if matches!(depl_to_del.ty, Some(Slot::Rollback)) && host.status.rollback_queued {
343        composefs_rollback(storage, booted_cfs).await?;
344    }
345
346    let kind = if depl_to_del.pinned {
347        "pinned "
348    } else if deleting_staged {
349        "staged "
350    } else {
351        ""
352    };
353
354    tracing::info!("Deleting {kind}deployment '{deployment_id}'");
355
356    delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?;
357
358    composefs_gc(storage, booted_cfs).await?;
359
360    Ok(())
361}