Skip to main content

bootc_lib/install/
baseline.rs

1//! # The baseline installer
2//!
3//! This module handles creation of simple root filesystem setups.  At the current time
4//! it's very simple - just a direct filesystem (e.g. xfs, ext4, btrfs etc.).  It is
5//! intended to add opinionated handling of TPM2-bound LUKS too.  But that's about it;
6//! other more complex flows should set things up externally and use `bootc install to-filesystem`.
7
8use std::borrow::Cow;
9use std::ffi::OsStr;
10use std::ffi::OsString;
11use std::fmt::Display;
12use std::fmt::Write as _;
13use std::io::Write;
14use std::num::NonZeroU64;
15use std::path::Path;
16use std::process::Command;
17use std::process::Stdio;
18
19use anyhow::Ok;
20use anyhow::{Context, Result};
21use bootc_blockdev::Device;
22use bootc_utils::CommandRunExt;
23use camino::Utf8Path;
24use camino::Utf8PathBuf;
25use cap_std::fs::Dir;
26use cap_std_ext::cap_std;
27use clap::ValueEnum;
28use fn_error_context::context;
29use serde::{Deserialize, Serialize};
30
31use super::AbootDiskLayout;
32use super::BOOT;
33use super::MountSpec;
34use super::RUN_BOOTC;
35use super::RW_KARG;
36use super::RootSetup;
37use super::State;
38use super::config::Filesystem;
39use crate::bootloader::systemd_version;
40use crate::task::Task;
41use bootc_blockdev::dps::BIOS_BOOT;
42#[cfg(feature = "install-to-disk")]
43use bootc_mount::is_mounted_in_pid1_mountns;
44use linux_kernel_cmdline::utf8::Cmdline;
45
46/// Check whether DPS auto-discovery is enabled.  When `true`,
47/// `root=UUID=` is omitted and `systemd-gpt-auto-generator` discovers
48/// the root partition via its DPS type GUID instead.
49///
50/// Defaults to `true` for systemd-boot (which always implements BLI).
51/// For GRUB the default is `false` because we cannot know at install
52/// time whether the GRUB build includes the `bli` module — the module
53/// is baked into the signed EFI binary with no external manifest.
54/// Distros shipping a BLI-capable GRUB should set
55/// `discoverable-partitions = true` in their install config.
56#[cfg(feature = "install-to-disk")]
57fn use_discoverable_partitions(state: &State) -> bool {
58    // Explicit config takes priority
59    if let Some(ref config) = state.install_config {
60        if let Some(v) = config.discoverable_partitions {
61            return v;
62        }
63    }
64    // systemd-boot always supports BLI
65    matches!(
66        state.config_opts.bootloader,
67        Some(crate::spec::Bootloader::Systemd) | Some(crate::spec::Bootloader::GrubCC)
68    )
69}
70
71// This ensures we end up under 512 to be small-sized.
72pub(crate) const BOOTPN_SIZE_MB: u32 = 510;
73// NOTE: If you change this or [`CFS_EFIPN_SIZE_MB`] below,
74// make sure to update the test test-install-reaprt.nu
75pub(crate) const EFIPN_SIZE_MB: u32 = 512;
76/// EFI Partition size for composefs installations
77/// We need more space than ostree as we have UKIs and UKI addons
78/// We might also need to store UKIs for pinned deployments
79pub(crate) const CFS_EFIPN_SIZE_MB: u32 = 2048;
80const ABOOT_BOOT_SIZE_MB: u32 = 192;
81const ABOOT_VBMETA_SIZE_MB: u32 = 64;
82const UKIBOOT_ESP_SIZE_MB: u32 = 100;
83const UKIBOOT_CTL_SIZE_MB: u32 = 1;
84pub(crate) const ABOOT_BOOT_GUID: &str = "20117f86-e985-4357-b9ee-374bc1d8487d";
85pub(crate) const ABOOT_VBMETA_GUID: &str = "4b7a15d6-322c-42ac-8110-88b7da0c5d77";
86pub(crate) const UKIBOOT_BOOT_GUID: &str = "df331e4d-be00-463f-b4a7-8b43e18fb53a";
87const UKIBOOT_CTL_GUID: &str = "fefd9070-346f-4c9a-85e6-17f07f922773";
88#[cfg(feature = "install-to-disk")]
89pub(crate) const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B";
90#[cfg(feature = "install-to-disk")]
91pub(crate) const PREPBOOT_LABEL: &str = "PowerPC-PReP-boot";
92
93#[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "kebab-case")]
95pub(crate) enum BlockSetup {
96    #[default]
97    Direct,
98    Tpm2Luks,
99}
100
101impl Display for BlockSetup {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        self.to_possible_value().unwrap().get_name().fmt(f)
104    }
105}
106
107/// Options for installing to a block device
108#[derive(Debug, Clone, clap::Args, Serialize, Deserialize, PartialEq, Eq)]
109#[serde(rename_all = "kebab-case")]
110pub(crate) struct InstallBlockDeviceOpts {
111    /// Target block device for installation.  The entire device will be wiped.
112    pub(crate) device: Utf8PathBuf,
113
114    /// Automatically wipe all existing data on device
115    #[clap(long)]
116    #[serde(default)]
117    pub(crate) wipe: bool,
118
119    /// Target root block device setup.
120    ///
121    /// direct: Filesystem written directly to block device
122    /// tpm2-luks: Bind unlock of filesystem to presence of the default tpm2 device.
123    #[clap(long, value_enum)]
124    pub(crate) block_setup: Option<BlockSetup>,
125
126    /// Target root filesystem type.
127    #[clap(long, value_enum)]
128    pub(crate) filesystem: Option<Filesystem>,
129
130    /// Size of the root partition (default specifier: M).  Allowed specifiers: M (mebibytes), G (gibibytes), T (tebibytes).
131    ///
132    /// By default, the root partition takes the remaining space, leaving room
133    /// for any other partitions the image's repart.d definitions describe.
134    #[clap(long)]
135    pub(crate) root_size: Option<String>,
136}
137
138impl BlockSetup {
139    /// Returns true if the block setup requires a separate /boot aka XBOOTLDR partition.
140    pub(crate) fn requires_bootpart(&self) -> bool {
141        match self {
142            BlockSetup::Direct => false,
143            BlockSetup::Tpm2Luks => true,
144        }
145    }
146}
147
148#[cfg(feature = "install-to-disk")]
149fn mkfs<'a>(
150    dev: &str,
151    fs: Filesystem,
152    label: &str,
153    wipe: bool,
154    opts: impl IntoIterator<Item = &'a str>,
155) -> Result<uuid::Uuid> {
156    let devinfo = bootc_blockdev::list_dev(dev.into())?;
157    let size = ostree_ext::glib::format_size(devinfo.size);
158
159    // Generate a random UUID for the filesystem
160    let u = uuid::Uuid::new_v4();
161
162    let mut t = Task::new(
163        &format!("Creating {label} filesystem ({fs}) on device {dev} (size={size})"),
164        format!("mkfs.{fs}"),
165    );
166    match fs {
167        Filesystem::Xfs => {
168            if wipe {
169                t.cmd.arg("-f");
170            }
171            t.cmd.arg("-m");
172            t.cmd.arg(format!("uuid={u}"));
173        }
174        Filesystem::Btrfs | Filesystem::Ext4 => {
175            t.cmd.arg("-U");
176            t.cmd.arg(u.to_string());
177        }
178    };
179    // Today all the above mkfs commands take -L
180    t.cmd.args(["-L", label]);
181    t.cmd.args(opts);
182    t.cmd.arg(dev);
183    // All the mkfs commands are unnecessarily noisy by default
184    t.cmd.stdout(Stdio::null());
185    // But this one is notable so let's print the whole thing with verbose()
186    t.verbose().run()?;
187    Ok(u)
188}
189
190pub(crate) fn wipefs(dev: &Utf8Path) -> Result<()> {
191    println!("Wiping device {dev}");
192    Command::new("wipefs")
193        .args(["-a", dev.as_str()])
194        .run_inherited_with_cmd_context()
195}
196
197pub(crate) fn udev_settle() -> Result<()> {
198    // There's a potential window after rereading the partition table where
199    // udevd hasn't yet received updates from the kernel, settle will return
200    // immediately, and lsblk won't pick up partition labels.  Try to sleep
201    // our way out of this.
202    std::thread::sleep(std::time::Duration::from_millis(200));
203
204    let st = super::run_in_host_mountns("udevadm")?
205        .arg("settle")
206        .status()?;
207    if !st.success() {
208        anyhow::bail!("Failed to run udevadm settle: {st:?}");
209    }
210    Ok(())
211}
212
213/// Partition numbers resulting from partitioning, used to look up devices after.
214#[derive(Debug)]
215struct PartitionLayout {
216    esp_partno: Option<u32>,
217    boot_partno: Option<u32>,
218    rootpn: u32,
219    /// Whether systemd-repart created the ESP/boot partitions (skip mkfs for those)
220    used_repart: bool,
221}
222
223/// The json output for systemd-repart
224#[derive(Debug, Deserialize)]
225struct RepartPartition {
226    /// Human readable partition name
227    /// Ex. "esp", "root-x86_64"
228    #[serde(rename = "type")]
229    partition_type: String,
230    #[serde(default)]
231    label: Option<String>,
232    /// 0-indexed partition number
233    /// absent in older systemd-repart
234    /// versions, in c9s
235    #[serde(default)]
236    partno: Option<u32>,
237    #[serde(default)]
238    raw_size: u64,
239    #[allow(dead_code)]
240    fs: Option<String>,
241    /// The file used to generate this partition
242    file: String,
243}
244
245fn has_systemd_repart() -> bool {
246    if Command::new("systemd-repart")
247        .arg("--help")
248        .stdout(Stdio::null())
249        .stderr(Stdio::null())
250        .status()
251        .is_err()
252    {
253        return false;
254    }
255
256    true
257}
258
259fn can_use_systemd_repart() -> bool {
260    if !has_systemd_repart() {
261        return false;
262    }
263
264    let has_config = REPART_CONFIG_DIRS.iter().map(Path::new).any(|d| {
265        d.is_dir()
266            && d.read_dir()
267                .ok()
268                .is_some_and(|mut entries| entries.next().is_some())
269    });
270
271    has_config
272}
273
274/// The first systemd version that supports `systemd-repart --include-partitions=`.
275const REPART_INCLUDE_PARTITIONS_MIN_VERSION: u32 = 253;
276
277/// The first systemd version whose systemd-repart honors the
278/// `SYSTEMD_REPART_MKFS_OPTIONS_<FSTYPE>` environment variable (used to enable
279/// ext4 fs-verity). Older versions silently ignore it.
280const REPART_MKFS_OPTIONS_MIN_VERSION: u32 = 254;
281
282/// The repart.d configuration search directories, in descending priority. A
283/// definition present in a higher-priority directory masks a same-named one
284/// below it (matching systemd's own semantics).
285const REPART_CONFIG_DIRS: &[&str] = &[
286    "/etc/repart.d",
287    "/run/repart.d",
288    "/usr/local/lib/repart.d",
289    "/usr/lib/repart.d",
290];
291
292/// A file name for the root partition definition we generate when the image's
293/// repart.d definitions have none. systemd-repart lays out partitions in the
294/// order of their definitions' names, so it sorts where `50-root.conf` would;
295/// the random part keeps it from masking a definition of the image, or from
296/// being masked by one.
297fn generated_root_definition_name() -> String {
298    format!("50-root-{}.conf", uuid::Uuid::new_v4())
299}
300
301/// The name of the drop-in that pins a definition's partition to its planned
302/// size. Drop-ins apply in name order, so the name sorts after those images
303/// usually ship, such as `override.conf` or `10-size.conf`.
304const REPART_PLANNED_SIZE_DROPIN: &str = "zz-bootc-planned-size.conf";
305
306/// Bytes per MiB, the unit we pin the generated root partition's size in.
307const MIB: u64 = 1024 * 1024;
308
309/// Whether we must emulate `--include-partitions=` by pre-filtering the
310/// definitions ourselves
311fn need_filtered_definitions(generic_image: bool) -> Result<bool> {
312    Ok(generic_image && systemd_version()? < REPART_INCLUDE_PARTITIONS_MIN_VERSION)
313}
314
315/// Whether a partition (as reported by systemd-repart) is one we must create
316/// even for a generic image: root, ESP or BIOS boot.
317fn repart_partition_is_required(part: &RepartPartition) -> bool {
318    let ptype = part.partition_type.as_str();
319    ptype.starts_with("root")
320        || ptype.starts_with("esp")
321        || ptype == "bios"
322        || ptype.eq_ignore_ascii_case(BIOS_BOOT)
323}
324
325/// Collect the selected repart.d definitions into dest_dir, a fresh directory.
326///
327/// systemd < 253 does not support the `--include-partitions=` option, so instead
328/// we point systemd-repart at a directory containing only the definitions we want
329/// it to act on
330fn collect_repart_definitions(
331    dry_partitions: &[RepartPartition],
332    dest_dir: &Path,
333    filter: impl Fn(&RepartPartition) -> bool,
334) -> Result<()> {
335    std::fs::create_dir_all(dest_dir)
336        .with_context(|| format!("Creating {}", dest_dir.display()))?;
337
338    for part in dry_partitions {
339        if !filter(part) {
340            continue;
341        }
342
343        // Older version of systemd-repart does not provide the full path to the file
344        // so we need to search one by one
345        let src = REPART_CONFIG_DIRS
346            .iter()
347            .map(|d| Path::new(d).join(&part.file))
348            .find(|p| p.exists())
349            .ok_or_else(|| anyhow::anyhow!("Could not find repart.d definition {}", part.file))?;
350        let name = src
351            .file_name()
352            .context("Missing repart.d definition filename")?;
353
354        std::fs::copy(&src, dest_dir.join(name))
355            .with_context(|| format!("Copying repart.d definition {}", src.display()))?;
356    }
357
358    Ok(())
359}
360
361/// The size in MiB to pin the root partition we generate to, given a dry run of
362/// all definitions in which it has no size limits. systemd-repart gives it the
363/// space the other definitions leave, padding included, and shares that by
364/// Weight= with any of them that can grow, so the partitions created only on
365/// first boot still fit after it. Rounded down to whole MiB.
366fn planned_root_size_mib(planned: &[RepartPartition]) -> Result<NonZeroU64> {
367    let root = planned
368        .iter()
369        .find(|p| p.partition_type.starts_with("root-"))
370        .ok_or_else(|| anyhow::anyhow!("systemd-repart planned no root partition"))?;
371    NonZeroU64::new(root.raw_size / MIB).ok_or_else(|| {
372        anyhow::anyhow!("systemd-repart planned less than 1 MiB for the root partition")
373    })
374}
375
376/// The partitions other than root that a generic image's install creates (the
377/// ESP and BIOS boot), as the file name of their definition and the size in
378/// bytes systemd-repart planned for them with all definitions.
379///
380/// That install leaves the other partitions to first boot, so a definition
381/// among these that can grow would take the space planned for them, unless it
382/// is pinned to its planned size.
383fn planned_boot_partition_sizes(planned: &[RepartPartition]) -> Result<Vec<(String, u64)>> {
384    planned
385        .iter()
386        .filter(|p| repart_partition_is_required(p) && !p.partition_type.starts_with("root"))
387        .map(|p| {
388            let name = Utf8Path::new(&p.file)
389                .file_name()
390                .ok_or_else(|| anyhow::anyhow!("Invalid repart.d definition path {}", p.file))?;
391            Ok((name.to_owned(), p.raw_size))
392        })
393        .collect()
394}
395
396/// The repart.d drop-in that pins a partition to `size` bytes.
397fn planned_size_pin(size: u64) -> String {
398    format!("[Partition]\nSizeMinBytes={size}\nSizeMaxBytes={size}\n")
399}
400
401/// Write the repart.d definition or drop-in `name` into `dir`, creating parent
402/// directories as needed.
403fn write_repart_file(dir: &Path, name: &str, conf: &str) -> Result<()> {
404    let path = dir.join(name);
405    if let Some(parent) = path.parent() {
406        std::fs::create_dir_all(parent)
407            .with_context(|| format!("Creating {}", parent.display()))?;
408    }
409    std::fs::write(&path, conf).with_context(|| format!("Writing {}", path.display()))
410}
411
412/// Create partitions using systemd-repart
413/// Assumes we have systemd-repart definitions
414#[context("Running systemd-repart")]
415fn systemd_repart(
416    device: &Device,
417    root_size: Option<u64>,
418    rootfs: Option<Filesystem>,
419    generic_image: bool,
420) -> Result<PartitionLayout> {
421    // Dry-run to check what partitions would be created
422    // Send `generic_image` as false so that we can see ALL defined
423    // partitions
424    let dry_partitions = systemd_repart_run(device, false, true, RepartDefinitions::Image)?;
425
426    if dry_partitions.is_empty() {
427        anyhow::bail!("systemd-repart returned empty partitions");
428    }
429
430    let has_root = dry_partitions
431        .iter()
432        .any(|p| p.partition_type.starts_with("root-"));
433
434    if has_root {
435        // Root partition is defined in repart.d config, run for real
436        let filtered;
437        let definitions = if need_filtered_definitions(generic_image)? {
438            filtered =
439                tempfile::tempdir().context("Creating a directory for repart.d definitions")?;
440            collect_repart_definitions(
441                &dry_partitions,
442                filtered.path(),
443                repart_partition_is_required,
444            )?;
445            RepartDefinitions::Only(filtered.path())
446        } else {
447            RepartDefinitions::Image
448        };
449        let partitions = systemd_repart_run(device, generic_image, false, definitions)?;
450        let layout = parse_repart_layout(&partitions)?;
451        return Ok(layout);
452    }
453
454    // Root partition is not defined, create definition for the root part
455    let Some(fs) = rootfs else {
456        anyhow::bail!("Rootfs not specified");
457    };
458    let mut root_conf = format!("[Partition]\nType=root\nFormat={fs}\n");
459    if let Some(size_mib) = root_size {
460        writeln!(root_conf, "SizeMinBytes={size_mib}M")?;
461        writeln!(root_conf, "SizeMaxBytes={size_mib}M")?;
462    }
463    // Not in /run/repart.d, which may be the host's
464    let generated = tempfile::tempdir().context("Creating a directory for repart.d definitions")?;
465    let root_name = generated_root_definition_name();
466
467    // A generic image's install creates only root, the ESP and BIOS boot, and
468    // leaves the other partitions to first boot. Plan the layout with all
469    // definitions, and have this run create those three at their planned
470    // sizes, so that none of them grows into the space planned for the
471    // others. With --root-size there is no such plan: the other partitions
472    // may only fit once the disk has been grown. Without a generic image, the
473    // real run creates all the partitions, and sizes an unsized root by itself.
474    let boot_partition_sizes = if generic_image && root_size.is_none() {
475        write_repart_file(generated.path(), &root_name, &root_conf)?;
476        let planned = systemd_repart_run(
477            device,
478            false,
479            true,
480            RepartDefinitions::WithGenerated(generated.path()),
481        )
482        .context("Planning the root partition size")?;
483
484        let size_mib = planned_root_size_mib(&planned)?.get();
485        tracing::debug!(
486            "Root partition size: {size_mib} MiB of {} MiB",
487            device.size / MIB
488        );
489        writeln!(root_conf, "SizeMinBytes={size_mib}M")?;
490        writeln!(root_conf, "SizeMaxBytes={size_mib}M")?;
491
492        planned_boot_partition_sizes(&planned)?
493    } else {
494        Vec::new()
495    };
496
497    let definitions = if need_filtered_definitions(generic_image)? {
498        // The run then reads only this directory, so it needs the copies too
499        collect_repart_definitions(
500            &dry_partitions,
501            generated.path(),
502            repart_partition_is_required,
503        )?;
504        RepartDefinitions::Only(generated.path())
505    } else {
506        RepartDefinitions::WithGenerated(generated.path())
507    };
508    write_repart_file(generated.path(), &root_name, &root_conf)?;
509    // For this run only: on first boot the partitions exist already
510    for (name, size) in &boot_partition_sizes {
511        let dropin = format!("{name}.d/{REPART_PLANNED_SIZE_DROPIN}");
512        write_repart_file(generated.path(), &dropin, &planned_size_pin(*size))?;
513    }
514
515    let partitions = systemd_repart_run(device, generic_image, false, definitions)?;
516    let layout = parse_repart_layout(&partitions)?;
517
518    Ok(layout)
519}
520
521/// Which repart.d definitions a systemd-repart run reads.
522#[derive(Debug, Clone, Copy)]
523enum RepartDefinitions<'a> {
524    /// The image's, from the standard search paths.
525    Image,
526    /// The image's plus those generated in this directory.
527    WithGenerated(&'a Path),
528    /// Only those in this directory, such as the copies a generic image needs
529    /// on systemd < 253 (see [`need_filtered_definitions`]).
530    Only(&'a Path),
531}
532
533impl RepartDefinitions<'_> {
534    /// The systemd-repart arguments that select these definitions, and for a
535    /// generic image the partitions to create.
536    fn args(self, generic_image: bool) -> Vec<OsString> {
537        let definitions = |dir: &OsStr| {
538            let mut arg = OsString::from("--definitions=");
539            arg.push(dir);
540            arg
541        };
542        let mut args = Vec::new();
543        match self {
544            // With a generic image, callers pass copies of just the definitions
545            // to act on this way where systemd lacks --include-partitions=.
546            Self::Only(dir) => args.push(definitions(dir.as_os_str())),
547            Self::Image | Self::WithGenerated(_) => {
548                if let Self::WithGenerated(generated) = self {
549                    args.extend(
550                        REPART_CONFIG_DIRS
551                            .iter()
552                            .map(|dir| definitions(OsStr::new(dir))),
553                    );
554                    args.push(definitions(generated.as_os_str()));
555                }
556                // If generic image, only run repart definitions that we absolutely
557                // require, (BIOS/ESP and root). Others can run at first boot since we
558                // don't know if the user would want to run those on this disk itself
559                // or if this disk would be used to create an AMI/VHD and a separate disk
560                // would be used for the other partitions. On systemd < 253, which
561                // lacks --include-partitions=, callers pass copies of just these
562                // definitions as Only instead, see need_filtered_definitions.
563                if generic_image {
564                    args.push(format!("--include-partitions=root,esp,{BIOS_BOOT}").into());
565                }
566            }
567        }
568        args
569    }
570}
571
572/// Run systemd-repart on the device and return the parsed JSON output.
573/// `dry_run`: if true, no changes are written to disk.
574/// `definitions`: which repart.d definitions to read.
575fn systemd_repart_run(
576    device: &Device,
577    generic_image: bool,
578    dry_run: bool,
579    definitions: RepartDefinitions,
580) -> Result<Vec<RepartPartition>> {
581    let mut cmd = Command::new("systemd-repart");
582
583    // Enable fsverity for ext4
584    // btrfs has fsverity enabled out of the box
585    //
586    // NOTE: systemd < v254 doesn't support this
587    cmd.env("SYSTEMD_REPART_MKFS_OPTIONS_EXT4", "-O verity");
588
589    let dry_run_arg = if dry_run {
590        "--dry-run=yes"
591    } else {
592        "--dry-run=no"
593    };
594    cmd.args([
595        dry_run_arg,
596        "--no-pager",
597        "--json=pretty",
598        "--empty=force",
599        "--root=/",
600    ]);
601
602    cmd.args(definitions.args(generic_image));
603    cmd.arg(device.path());
604
605    let output = cmd.output().context("Failed to run systemd-repart")?;
606
607    if !output.status.success() {
608        let stderr = String::from_utf8_lossy(&output.stderr);
609        anyhow::bail!("systemd-repart failed: {stderr}");
610    }
611
612    if !dry_run {
613        tracing::debug!(
614            "Partitions ({}): {}",
615            if dry_run { "Dry Run" } else { "Actual" },
616            std::str::from_utf8(&output.stdout).unwrap_or("Failed to serialize output as UTF-8")
617        );
618    }
619
620    serde_json::from_slice(&output.stdout).with_context(|| {
621        format!(
622            "Failed to deserialize systemd-repart output: {}",
623            String::from_utf8_lossy(&output.stdout)
624        )
625    })
626}
627
628/// Parse partition layout from systemd-repart JSON output.
629fn parse_repart_layout(partitions: &[RepartPartition]) -> Result<PartitionLayout> {
630    let mut esp_partno = None;
631    let mut boot_partno = None;
632    let mut rootpn = None;
633
634    for (idx, part) in partitions.iter().enumerate() {
635        // repart partno is 0-indexed, lsblk/sfdisk use 1-indexed
636        // Fall back to array indexing for older systemd-repart without partno
637        // like the version in c9s
638        let partno = part.partno.unwrap_or(idx as u32) + 1;
639
640        match part.partition_type.as_str() {
641            "esp" => esp_partno = Some(partno),
642            "xbootldr" => boot_partno = Some(partno),
643            t if t.starts_with("root-") => rootpn = Some(partno),
644            _ => {}
645        }
646    }
647
648    let rootpn =
649        rootpn.ok_or_else(|| anyhow::anyhow!("systemd-repart output missing root partition"))?;
650
651    Ok(PartitionLayout {
652        esp_partno,
653        boot_partno,
654        rootpn,
655        used_repart: true,
656    })
657}
658
659fn aboot_partition_labels(layout: AbootDiskLayout) -> &'static [&'static str] {
660    match layout {
661        AbootDiskLayout::Android => &["boot_a", "boot_b", "vbmeta_a", "vbmeta_b"],
662        AbootDiskLayout::Ukiboot => &["ukiboot_a", "ukiboot_b", "ukibootctl"],
663    }
664}
665
666fn aboot_partition_type(label: &str) -> &'static str {
667    match label {
668        "boot_a" | "boot_b" => ABOOT_BOOT_GUID,
669        "vbmeta_a" | "vbmeta_b" => ABOOT_VBMETA_GUID,
670        "ukiboot_a" | "ukiboot_b" => UKIBOOT_BOOT_GUID,
671        "ukibootctl" => UKIBOOT_CTL_GUID,
672        _ => unreachable!("unknown aboot partition label"),
673    }
674}
675
676fn validate_aboot_repart_partitions(
677    partitions: &[RepartPartition],
678    layout: AbootDiskLayout,
679) -> Result<()> {
680    let other_layout = match layout {
681        AbootDiskLayout::Android => AbootDiskLayout::Ukiboot,
682        AbootDiskLayout::Ukiboot => AbootDiskLayout::Android,
683    };
684    let labels = aboot_partition_labels(layout);
685    let other_labels = aboot_partition_labels(other_layout);
686    anyhow::ensure!(
687        !partitions
688            .iter()
689            .any(|p| other_labels.contains(&p.label.as_deref().unwrap_or(""))),
690        "repart.d contains partitions for a different aboot layout"
691    );
692    for label in labels {
693        let count = partitions
694            .iter()
695            .filter(|p| p.label.as_deref() == Some(*label))
696            .count();
697        anyhow::ensure!(
698            count <= 1,
699            "Duplicate {label} partition in aboot repart.d layout"
700        );
701        if let Some(partition) = partitions
702            .iter()
703            .find(|p| p.label.as_deref() == Some(*label))
704        {
705            anyhow::ensure!(
706                partition
707                    .partition_type
708                    .eq_ignore_ascii_case(aboot_partition_type(label)),
709                "aboot partition {label} has the wrong type GUID"
710            );
711        }
712    }
713    Ok(())
714}
715
716fn validate_repart_boot_layout(
717    partitions: &[RepartPartition],
718    layout: Option<AbootDiskLayout>,
719) -> Result<()> {
720    if let Some(layout) = layout {
721        return validate_aboot_repart_partitions(partitions, layout);
722    }
723    anyhow::ensure!(
724        !partitions
725            .iter()
726            .any(|p| [AbootDiskLayout::Android, AbootDiskLayout::Ukiboot]
727                .iter()
728                .any(|layout| aboot_partition_labels(*layout)
729                    .contains(&p.label.as_deref().unwrap_or("")))),
730        "aboot repart.d layout requires a matching aboot boot artifact"
731    );
732    Ok(())
733}
734
735fn image_repart_partitions(
736    device: &Device,
737    layout: Option<AbootDiskLayout>,
738) -> Result<Vec<RepartPartition>> {
739    if !can_use_systemd_repart() {
740        return Ok(Vec::new());
741    }
742    let partitions = systemd_repart_run(device, false, true, RepartDefinitions::Image)?;
743    validate_repart_boot_layout(&partitions, layout)?;
744    Ok(partitions)
745}
746
747fn verify_image_aboot_filesystems(
748    device: &Device,
749    aboot_layout: AbootDiskLayout,
750    partition_layout: &PartitionLayout,
751) -> Result<()> {
752    let labels = aboot_partition_labels(aboot_layout);
753    for label in labels {
754        let partition = device
755            .children
756            .iter()
757            .flatten()
758            .find(|part| part.partlabel.as_deref() == Some(label))
759            .ok_or_else(|| anyhow::anyhow!("Missing {label} partition"))?;
760        anyhow::ensure!(
761            partition
762                .parttype
763                .as_deref()
764                .is_some_and(|guid| guid.eq_ignore_ascii_case(aboot_partition_type(label))),
765            "aboot partition {label} has the wrong type GUID"
766        );
767        anyhow::ensure!(
768            partition.fstype.is_none(),
769            "aboot partition {label} must not contain a filesystem"
770        );
771    }
772    if aboot_layout == AbootDiskLayout::Ukiboot {
773        let esp = device
774            .find_device_by_partno(partition_layout.esp_partno.context("Missing ukiboot ESP")?)?;
775        anyhow::ensure!(
776            esp.fstype.as_deref() == Some("vfat"),
777            "ukiboot ESP must be formatted as vfat"
778        );
779    }
780    Ok(())
781}
782
783fn aboot_repart_definitions(
784    layout: AbootDiskLayout,
785    root_size: Option<u64>,
786    rootfs: Option<Filesystem>,
787    partitions: &[RepartPartition],
788) -> Result<Vec<(String, String)>> {
789    let mut definitions = Vec::new();
790    if layout == AbootDiskLayout::Ukiboot && !partitions.iter().any(|p| p.partition_type == "esp") {
791        let esp = format!(
792            "[Partition]\nType=esp\nFormat=vfat\nSizeMinBytes={UKIBOOT_ESP_SIZE_MB}M\nSizeMaxBytes={UKIBOOT_ESP_SIZE_MB}M\n"
793        );
794        definitions.push(("05-efi.conf".into(), esp));
795    }
796    let mut add = |order: u32, name: &str, part_type: &str, size: u32, flags: Option<&str>| {
797        if partitions.iter().any(|p| p.label.as_deref() == Some(name)) {
798            return;
799        }
800        let mut config = format!(
801            "[Partition]\nType={part_type}\nLabel={name}\nSizeMinBytes={size}M\nSizeMaxBytes={size}M\n"
802        );
803        if let Some(flags) = flags {
804            config.push_str(&format!("Flags={flags}\n"));
805        }
806        definitions.push((format!("{order:02}-{name}.conf"), config));
807    };
808
809    match layout {
810        AbootDiskLayout::Android => {
811            add(
812                10,
813                "boot_a",
814                ABOOT_BOOT_GUID,
815                ABOOT_BOOT_SIZE_MB,
816                Some("0x3f000000000000"),
817            );
818            add(
819                11,
820                "boot_b",
821                ABOOT_BOOT_GUID,
822                ABOOT_BOOT_SIZE_MB,
823                Some("0x3d000000000000"),
824            );
825            for (order, name) in [(12, "vbmeta_a"), (13, "vbmeta_b")] {
826                add(order, name, ABOOT_VBMETA_GUID, ABOOT_VBMETA_SIZE_MB, None);
827            }
828        }
829        AbootDiskLayout::Ukiboot => {
830            for (order, name) in [(10, "ukiboot_a"), (11, "ukiboot_b")] {
831                add(order, name, UKIBOOT_BOOT_GUID, ABOOT_BOOT_SIZE_MB, None);
832            }
833            add(
834                12,
835                "ukibootctl",
836                UKIBOOT_CTL_GUID,
837                UKIBOOT_CTL_SIZE_MB,
838                None,
839            );
840        }
841    }
842
843    if partitions
844        .iter()
845        .any(|p| p.partition_type.starts_with("root-"))
846    {
847        return Ok(definitions);
848    }
849    let rootfs = rootfs.context(
850        "No root repart.d definition found; specify --filesystem or a root filesystem in the install config",
851    )?;
852    let mut root = format!("[Partition]\nType=root\nFormat={rootfs}\n");
853    if let Some(size) = root_size {
854        root.push_str(&format!("SizeMinBytes={size}M\nSizeMaxBytes={size}M\n"));
855    }
856    definitions.push(("50-root.conf".into(), root));
857    Ok(definitions)
858}
859
860#[context("Running systemd-repart for aboot")]
861fn systemd_repart_aboot(
862    device: &Device,
863    layout: AbootDiskLayout,
864    image_partitions: &[RepartPartition],
865    generated_definitions: &[(String, String)],
866    generic_image: bool,
867) -> Result<PartitionLayout> {
868    let definitions = tempfile::tempdir().context("Creating aboot repart definitions directory")?;
869    collect_repart_definitions(image_partitions, definitions.path(), |part| {
870        !generic_image
871            || repart_partition_is_required(part)
872            || aboot_partition_labels(layout).contains(&part.label.as_deref().unwrap_or(""))
873    })?;
874    for (name, config) in generated_definitions {
875        std::fs::OpenOptions::new()
876            .write(true)
877            .create_new(true)
878            .open(definitions.path().join(name))
879            .with_context(|| format!("Creating generated repart definition {name}"))?
880            .write_all(config.as_bytes())?;
881    }
882    let partitions = systemd_repart_run(
883        device,
884        false,
885        false,
886        RepartDefinitions::Only(definitions.path()),
887    )?;
888    parse_repart_layout(&partitions)
889}
890
891/// Use sfdisk to create partitions
892#[context("Running sfdisk")]
893fn sfdisk(
894    device: &Device,
895    root_size: Option<u64>,
896    composefs_backend: bool,
897    requires_bootpart: bool,
898) -> Result<PartitionLayout> {
899    // Generate partitioning spec as input to sfdisk
900    let mut partno = 0;
901    let mut partitioning_buf = String::new();
902    writeln!(partitioning_buf, "label: gpt")?;
903    let random_label = uuid::Uuid::new_v4();
904    writeln!(&mut partitioning_buf, "label-id: {random_label}")?;
905    if cfg!(target_arch = "x86_64") {
906        partno += 1;
907        writeln!(
908            &mut partitioning_buf,
909            r#"size=1MiB, bootable, type=21686148-6449-6E6F-744E-656564454649, name="BIOS-BOOT""#
910        )?;
911    } else if cfg!(target_arch = "powerpc64") {
912        // PowerPC-PReP-boot
913        partno += 1;
914        let label = PREPBOOT_LABEL;
915        let uuid = PREPBOOT_GUID;
916        writeln!(
917            &mut partitioning_buf,
918            r#"size=4MiB, bootable, type={uuid}, name="{label}""#
919        )?;
920    } else if cfg!(any(target_arch = "aarch64", target_arch = "s390x")) {
921        // No bootloader partition is necessary
922    } else {
923        anyhow::bail!("Unsupported architecture: {}", std::env::consts::ARCH);
924    }
925
926    let esp_partno = if super::ARCH_USES_EFI {
927        let esp_guid = bootc_blockdev::dps::ESP;
928        partno += 1;
929
930        let esp_size = if composefs_backend {
931            CFS_EFIPN_SIZE_MB
932        } else {
933            EFIPN_SIZE_MB
934        };
935
936        writeln!(
937            &mut partitioning_buf,
938            r#"size={esp_size}MiB, type={esp_guid}, name="EFI-SYSTEM""#
939        )?;
940        Some(partno)
941    } else {
942        None
943    };
944
945    // Initialize the /boot filesystem.  Note that in the future, we may match
946    // what systemd/uapi-group encourages and make /boot be FAT32 as well, as
947    // it would aid systemd-boot.
948    let boot_partno = if requires_bootpart {
949        partno += 1;
950        writeln!(
951            &mut partitioning_buf,
952            r#"size={BOOTPN_SIZE_MB}MiB, name="boot""#
953        )?;
954        Some(partno)
955    } else {
956        None
957    };
958    let rootpn = partno + 1;
959    let root_size = root_size
960        .map(|v| Cow::Owned(format!("size={v}MiB, ")))
961        .unwrap_or_else(|| Cow::Borrowed(""));
962    let rootpart_uuid = uuid::Uuid::parse_str(bootc_blockdev::dps::this_arch_root())?;
963    writeln!(
964        &mut partitioning_buf,
965        r#"{root_size}type={rootpart_uuid}, name="root""#
966    )?;
967    tracing::debug!("Partitioning: {partitioning_buf}");
968    Task::new("Initializing partitions", "sfdisk")
969        .arg("--wipe=always")
970        .arg(device.path())
971        .quiet()
972        .run_with_stdin_buf(Some(partitioning_buf.as_bytes()))
973        .context("Failed to run sfdisk")?;
974
975    Ok(PartitionLayout {
976        esp_partno,
977        boot_partno,
978        rootpn,
979        used_repart: false,
980    })
981}
982
983#[context("Creating rootfs")]
984#[cfg(feature = "install-to-disk")]
985pub(crate) fn install_create_rootfs(
986    state: &State,
987    opts: InstallBlockDeviceOpts,
988    run_repart: bool,
989) -> Result<RootSetup> {
990    let install_config = state.install_config.as_ref();
991    let luks_name = "root";
992    // Verify that the target is empty (if not already wiped in particular, but it's
993    // also good to verify that the wipe worked)
994    let mut device = bootc_blockdev::list_dev(&opts.device)?;
995
996    // Always disallow writing to mounted device
997    if is_mounted_in_pid1_mountns(&device.path())? {
998        anyhow::bail!("Device {} is mounted", device.path())
999    }
1000
1001    // Handle wiping any existing data
1002    if opts.wipe {
1003        let dev = &opts.device;
1004        for child in device.children.iter().flatten() {
1005            let child = child.path();
1006            println!("Wiping {child}");
1007            wipefs(Utf8Path::new(&child))?;
1008        }
1009        println!("Wiping {dev}");
1010        wipefs(dev)?;
1011    } else if device.has_children() {
1012        anyhow::bail!(
1013            "Detected existing partitions on {}; use e.g. `wipefs` or --wipe if you intend to overwrite",
1014            opts.device
1015        );
1016    }
1017
1018    let run_bootc = Utf8Path::new(RUN_BOOTC);
1019    let mntdir = run_bootc.join("mounts");
1020    if mntdir.exists() {
1021        std::fs::remove_dir_all(&mntdir)?;
1022    }
1023
1024    if state.aboot_disk_layout.is_some() {
1025        anyhow::ensure!(
1026            has_systemd_repart(),
1027            "aboot to-disk install requires systemd-repart"
1028        );
1029    }
1030    let image_partitions = if state.aboot_disk_layout.is_some() || run_repart {
1031        image_repart_partitions(&device, state.aboot_disk_layout)?
1032    } else {
1033        Vec::new()
1034    };
1035    let use_systemd_repart =
1036        state.aboot_disk_layout.is_some() || (run_repart && can_use_systemd_repart());
1037
1038    // Use the install configuration to find the block setup, if we have one
1039    let block_setup = if let Some(config) = install_config {
1040        config.get_block_setup(opts.block_setup.as_ref().copied())?
1041    } else if opts.filesystem.is_some() || use_systemd_repart {
1042        // Otherwise, if a filesystem is specified then we default to whatever was
1043        // specified via --block-setup, or the default
1044        opts.block_setup.unwrap_or_default()
1045    } else {
1046        // If there was no default filesystem, then there's no default block setup,
1047        // and we need to error out.
1048        anyhow::bail!("No install configuration found, and no filesystem specified")
1049    };
1050    let serial = device.serial.as_deref().unwrap_or("<unknown>");
1051    let model = device.model.as_deref().unwrap_or("<unknown>");
1052    let discoverable = state.aboot_disk_layout.is_none() && use_discoverable_partitions(state);
1053    if let Some(layout) = state.aboot_disk_layout {
1054        anyhow::ensure!(
1055            block_setup == BlockSetup::Direct,
1056            "{layout:?} aboot layout requires --block-setup direct"
1057        );
1058        anyhow::ensure!(
1059            layout != AbootDiskLayout::Ukiboot || super::ARCH_USES_EFI,
1060            "ukiboot disk layout requires x86_64 or aarch64"
1061        );
1062        anyhow::ensure!(
1063            state.composefs_options.composefs_backend,
1064            "aboot disk layouts require the composefs backend"
1065        );
1066    }
1067    println!("Block setup: {block_setup}");
1068    println!("       Size: {}", device.size);
1069    println!("     Serial: {serial}");
1070    println!("      Model: {model}");
1071    println!(
1072        " Partitions: {}",
1073        if discoverable { "Discoverable" } else { "UUID" }
1074    );
1075
1076    let root_size = opts
1077        .root_size
1078        .as_deref()
1079        .map(bootc_blockdev::parse_size_mib)
1080        .transpose()
1081        .context("Parsing root size")?;
1082    let aboot_definitions = state
1083        .aboot_disk_layout
1084        .map(|layout| {
1085            aboot_repart_definitions(
1086                layout,
1087                root_size,
1088                opts.filesystem.or(install_config
1089                    .and_then(|c| c.filesystem_root())
1090                    .and_then(|r| r.fstype)),
1091                &image_partitions,
1092            )
1093        })
1094        .transpose()?;
1095
1096    // Load the policy from the container root, which also must be our install root
1097    let sepolicy = state.load_policy()?;
1098    let sepolicy = sepolicy.as_ref();
1099
1100    // Create a temporary directory to use for mount points.  Note that we're
1101    // in a mount namespace, so these should not be visible on the host.
1102    let physical_root_path = mntdir.join("rootfs");
1103    std::fs::create_dir_all(&physical_root_path)?;
1104    let bootfs = mntdir.join("boot");
1105    std::fs::create_dir_all(bootfs)?;
1106
1107    let layout = if let Some(aboot_disk_layout) = state.aboot_disk_layout {
1108        systemd_repart_aboot(
1109            &device,
1110            aboot_disk_layout,
1111            &image_partitions,
1112            aboot_definitions.as_deref().unwrap(),
1113            state.config_opts.generic_image,
1114        )?
1115    } else if use_systemd_repart {
1116        systemd_repart(
1117            &device,
1118            root_size,
1119            opts.filesystem,
1120            state.config_opts.generic_image,
1121        )?
1122    } else {
1123        sfdisk(
1124            &device,
1125            root_size,
1126            state.composefs_options.composefs_backend,
1127            block_setup.requires_bootpart(),
1128        )?
1129    };
1130
1131    tracing::debug!(
1132        "Created partition table using {}",
1133        if layout.used_repart {
1134            "systemd-repart"
1135        } else {
1136            "sfdisk"
1137        }
1138    );
1139
1140    // Full udev sync; it'd obviously be better to await just the devices
1141    // we're targeting, but this is a simple coarse hammer.
1142    udev_settle()?;
1143
1144    // Re-read partition table to get updated children
1145    device.refresh()?;
1146
1147    if let Some(aboot_layout) = state.aboot_disk_layout {
1148        verify_image_aboot_filesystems(&device, aboot_layout, &layout)?;
1149    }
1150
1151    // Ensure we have a root filesystem
1152    let root_filesystem = if layout.used_repart {
1153        let root = device.find_device_by_partno(layout.rootpn)?;
1154        root.fstype
1155            .as_ref()
1156            .ok_or_else(|| anyhow::anyhow!("repart: Root filesystem type not defined"))?
1157    } else {
1158        let root_filesystem = opts
1159            .filesystem
1160            .or(install_config
1161                .and_then(|c| c.filesystem_root())
1162                .and_then(|r| r.fstype))
1163            .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?;
1164
1165        &root_filesystem.to_string()
1166    };
1167
1168    let root_device = device.find_device_by_partno(layout.rootpn)?;
1169
1170    // Verify the partition type matches the DPS root partition type for this architecture
1171    let expected_parttype = bootc_blockdev::dps::this_arch_root();
1172    if !root_device
1173        .parttype
1174        .as_ref()
1175        .is_some_and(|pt| pt.eq_ignore_ascii_case(expected_parttype))
1176    {
1177        anyhow::bail!(
1178            "root partition {} has type {}; expected {expected_parttype}",
1179            layout.rootpn,
1180            root_device.parttype.as_deref().unwrap_or("<none>")
1181        );
1182    }
1183    let (rootdev_path, root_blockdev_kargs) = match block_setup {
1184        BlockSetup::Direct => (root_device.path(), None),
1185        BlockSetup::Tpm2Luks => {
1186            let uuid = uuid::Uuid::new_v4().to_string();
1187            // This will be replaced via --wipe-slot=all when binding to tpm below
1188            let dummy_passphrase = uuid::Uuid::new_v4().to_string();
1189            let mut tmp_keyfile = tempfile::NamedTempFile::new()?;
1190            tmp_keyfile.write_all(dummy_passphrase.as_bytes())?;
1191            tmp_keyfile.flush()?;
1192            let tmp_keyfile = tmp_keyfile.path();
1193            let dummy_passphrase_input = Some(dummy_passphrase.as_bytes());
1194
1195            let root_devpath = root_device.path();
1196
1197            Task::new("Initializing LUKS for root", "cryptsetup")
1198                .args(["luksFormat", "--uuid", uuid.as_str(), "--key-file"])
1199                .args([tmp_keyfile])
1200                .arg(&root_devpath)
1201                .run()?;
1202            // The --wipe-slot=all removes our temporary passphrase, and binds to the local TPM device.
1203            // We also use .verbose() here as the details are important/notable.
1204            Task::new("Enrolling root device with TPM", "systemd-cryptenroll")
1205                .args(["--wipe-slot=all", "--tpm2-device=auto", "--unlock-key-file"])
1206                .args([tmp_keyfile])
1207                .arg(&root_devpath)
1208                .verbose()
1209                .run_with_stdin_buf(dummy_passphrase_input)?;
1210            Task::new("Opening root LUKS device", "cryptsetup")
1211                .args(["luksOpen", &root_devpath, luks_name])
1212                .run()?;
1213            let rootdev = format!("/dev/mapper/{luks_name}");
1214            let kargs = vec![
1215                format!("luks.uuid={uuid}"),
1216                format!("luks.options=tpm2-device=auto,headless=true"),
1217            ];
1218            (rootdev, Some(kargs))
1219        }
1220    };
1221
1222    // Initialize the /boot filesystem
1223    let bootdev = if let Some(bootpn) = layout.boot_partno {
1224        Some(device.find_device_by_partno(bootpn)?)
1225    } else {
1226        None
1227    };
1228
1229    let boot_uuid = match bootdev {
1230        Some(bootdev) => {
1231            let u = if layout.used_repart {
1232                let u = bootdev
1233                    .uuid
1234                    .as_ref()
1235                    .ok_or_else(|| anyhow::anyhow!("bootdev UUID not found"))?;
1236
1237                u.parse::<uuid::Uuid>()
1238                    .with_context(|| format!("Parsing bootdev UUID {u}"))?
1239            } else {
1240                mkfs(
1241                    &bootdev.path(),
1242                    root_filesystem.as_str().try_into()?,
1243                    "boot",
1244                    opts.wipe,
1245                    [],
1246                )
1247                .context("Initializing /boot")?
1248            };
1249
1250            Some(u)
1251        }
1252        None => None,
1253    };
1254
1255    let root_uuid = if layout.used_repart {
1256        // systemd-repart creates filesystem, just read the UUID it assigned
1257        let u = root_device.uuid.as_ref().ok_or_else(|| {
1258            anyhow::anyhow!("Root device created by repart has no filesystem UUID")
1259        })?;
1260
1261        // systemd < v254 (e.g. c9s) doesn't honor SYSTEMD_REPART_MKFS_OPTIONS_EXT4
1262        // so enable verity ourselves
1263        if systemd_version()? < REPART_MKFS_OPTIONS_MIN_VERSION && root_filesystem == "ext4" {
1264            tracing::debug!("Manually enabling fs-verity on root partition");
1265
1266            Command::new("tune2fs")
1267                .args(["-O", "verity", root_device.path().as_str()])
1268                .run_inherited()
1269                .context("Running tune2fs enabling fs-verity")?;
1270        }
1271
1272        u.parse::<uuid::Uuid>()
1273            .with_context(|| format!("Parsing root fs UUID {u}"))?
1274    } else {
1275        let rootfs: Filesystem = root_filesystem.as_str().try_into()?;
1276
1277        // Unconditionally enable fsverity for ext4
1278        let mkfs_options = match rootfs {
1279            Filesystem::Ext4 => ["-O", "verity"].as_slice(),
1280            _ => [].as_slice(),
1281        };
1282
1283        mkfs(
1284            &rootdev_path,
1285            rootfs,
1286            "root",
1287            opts.wipe,
1288            mkfs_options.iter().copied(),
1289        )?
1290    };
1291    let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}"));
1292    let bootarg = bootsrc.as_deref().map(|bootsrc| format!("boot={bootsrc}"));
1293    let boot = bootsrc.map(|bootsrc| MountSpec {
1294        source: bootsrc,
1295        target: format!("/{BOOT}"),
1296        fstype: MountSpec::AUTO.into(),
1297        options: Some("ro".into()),
1298    });
1299
1300    let mut kargs = Cmdline::new();
1301
1302    // Add root blockdev kargs (e.g., LUKS parameters)
1303    if let Some(root_blockdev_kargs) = root_blockdev_kargs {
1304        for karg in root_blockdev_kargs {
1305            kargs.extend(&Cmdline::from(karg.as_str()));
1306        }
1307    }
1308
1309    // When discoverable-partitions is enabled, omit root= so that
1310    // systemd-gpt-auto-generator discovers root by its DPS type GUID.
1311    if discoverable {
1312        kargs.extend(&Cmdline::from(RW_KARG));
1313    } else {
1314        let rootarg = format!("root=UUID={root_uuid}");
1315        kargs.extend(&Cmdline::from(format!("{rootarg} {RW_KARG}")));
1316    }
1317
1318    // Add boot= argument if present
1319    if let Some(bootarg) = bootarg {
1320        kargs.extend(&Cmdline::from(bootarg.as_str()));
1321    }
1322
1323    // Add CLI kargs
1324    if let Some(cli_kargs) = state.config_opts.karg.as_ref() {
1325        for karg in cli_kargs {
1326            kargs.extend(karg);
1327        }
1328    }
1329
1330    let fstype = &root_filesystem.to_string();
1331    bootc_mount::mount_typed(&rootdev_path, fstype, &physical_root_path)?;
1332    let target_rootfs = Dir::open_ambient_dir(&physical_root_path, cap_std::ambient_authority())?;
1333    crate::lsm::ensure_dir_labeled(&target_rootfs, "", Some("/".into()), 0o755.into(), sepolicy)?;
1334    let physical_root = Dir::open_ambient_dir(&physical_root_path, cap_std::ambient_authority())?;
1335    let bootfs = physical_root_path.join(BOOT);
1336    // Create the underlying mount point directory, which should be labeled
1337    crate::lsm::ensure_dir_labeled(&target_rootfs, BOOT, None, 0o755.into(), sepolicy)?;
1338    if let Some(bootdev) = bootdev {
1339        bootc_mount::mount_typed(&bootdev.path(), fstype, &bootfs)?;
1340    }
1341    // And we want to label the root mount of /boot
1342    crate::lsm::ensure_dir_labeled(&target_rootfs, BOOT, None, 0o755.into(), sepolicy)?;
1343
1344    // Create the EFI system partition, if applicable
1345    if let Some(esp_partno) = layout.esp_partno {
1346        let espdev = device.find_device_by_partno(esp_partno)?;
1347        if !layout.used_repart {
1348            Task::new("Creating ESP filesystem", "mkfs.fat")
1349                .args([&espdev.path(), "-n", "EFI-SYSTEM"])
1350                .verbose()
1351                .quiet_output()
1352                .run()?;
1353        }
1354        let efifs_path = bootfs.join(crate::bootloader::EFI_DIR);
1355        std::fs::create_dir(&efifs_path).context("Creating efi dir")?;
1356    }
1357
1358    let luks_device = match block_setup {
1359        BlockSetup::Direct => None,
1360        BlockSetup::Tpm2Luks => Some(luks_name.to_string()),
1361    };
1362    Ok(RootSetup {
1363        luks_device,
1364        device_info: device,
1365        physical_root_path,
1366        physical_root,
1367        target_root_path: None,
1368        rootfs_uuid: Some(root_uuid.to_string()),
1369        aboot_disk_layout: state.aboot_disk_layout,
1370        disk_install: true,
1371        boot,
1372        kargs,
1373        skip_finalize: false,
1374    })
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380
1381    #[test]
1382    fn aboot_repart_specs() {
1383        let android =
1384            aboot_repart_definitions(AbootDiskLayout::Android, None, Some(Filesystem::Ext4), &[])
1385                .unwrap();
1386        assert_eq!(android.len(), 5);
1387        assert_eq!(android[0].0, "10-boot_a.conf");
1388        assert!(android[0].1.contains("Flags=0x3f000000000000\n"));
1389        assert!(android[1].1.contains("Flags=0x3d000000000000\n"));
1390        assert!(android[4].1.contains("Type=root\nFormat=ext4\n"));
1391
1392        let ukiboot = aboot_repart_definitions(
1393            AbootDiskLayout::Ukiboot,
1394            Some(4096),
1395            Some(Filesystem::Ext4),
1396            &[],
1397        )
1398        .unwrap();
1399        assert_eq!(ukiboot.len(), 5);
1400        assert_eq!(ukiboot[0].0, "05-efi.conf");
1401        assert!(ukiboot[0].1.contains("Type=esp\nFormat=vfat\n"));
1402        assert!(
1403            ukiboot[4]
1404                .1
1405                .contains("SizeMinBytes=4096M\nSizeMaxBytes=4096M\n")
1406        );
1407    }
1408
1409    #[test]
1410    fn image_aboot_repart_layout() -> Result<()> {
1411        let mut partitions: Vec<RepartPartition> = serde_json::from_value(serde_json::json!([
1412            { "type": "20117f86-e985-4357-b9ee-374bc1d8487d", "label": "boot_a", "file": "10-boot-a.conf" },
1413            { "type": "20117f86-e985-4357-b9ee-374bc1d8487d", "label": "boot_b", "file": "11-boot-b.conf" },
1414            { "type": "4b7a15d6-322c-42ac-8110-88b7da0c5d77", "label": "vbmeta_a", "file": "12-vbmeta-a.conf" },
1415            { "type": "4b7a15d6-322c-42ac-8110-88b7da0c5d77", "label": "vbmeta_b", "file": "13-vbmeta-b.conf" },
1416            { "type": "root-arm64", "label": "root", "file": "50-root.conf" }
1417        ]))?;
1418        validate_aboot_repart_partitions(&partitions, AbootDiskLayout::Android)?;
1419        assert!(validate_repart_boot_layout(&partitions, None).is_err());
1420        assert!(validate_repart_boot_layout(&partitions, Some(AbootDiskLayout::Ukiboot)).is_err());
1421        validate_repart_boot_layout(&[], None)?;
1422        let correct_type =
1423            std::mem::replace(&mut partitions[0].partition_type, "linux-generic".into());
1424        assert!(validate_aboot_repart_partitions(&partitions, AbootDiskLayout::Android).is_err());
1425        partitions[0].partition_type = correct_type;
1426
1427        let unrelated: RepartPartition = serde_json::from_value(serde_json::json!({
1428            "type": ABOOT_BOOT_GUID,
1429            "label": "cache",
1430            "file": "20-cache.conf"
1431        }))?;
1432        assert!(!repart_partition_is_required(&partitions[0]));
1433        assert!(!repart_partition_is_required(&unrelated));
1434        validate_aboot_repart_partitions(&[], AbootDiskLayout::Android)?;
1435        validate_aboot_repart_partitions(&partitions[..3], AbootDiskLayout::Android)?;
1436        let missing =
1437            aboot_repart_definitions(AbootDiskLayout::Android, None, None, &partitions[1..])?;
1438        assert_eq!(missing.len(), 1);
1439        assert_eq!(missing[0].0, "10-boot_a.conf");
1440        Ok(())
1441    }
1442
1443    #[test]
1444    fn aboot_preserves_image_repart_definitions() -> Result<()> {
1445        for (layout, generic_image) in [
1446            (AbootDiskLayout::Android, false),
1447            (AbootDiskLayout::Android, true),
1448            (AbootDiskLayout::Ukiboot, false),
1449            (AbootDiskLayout::Ukiboot, true),
1450        ] {
1451            let source = tempfile::tempdir()?;
1452            let destination = tempfile::tempdir()?;
1453            let root_config = "[Partition]\nType=root\nFormat=ext4\nLabel=custom-root\nSizeMinBytes=2G\nSizeMaxBytes=4G\n";
1454            let root_file = source.path().join("20-root.conf");
1455            std::fs::write(&root_file, root_config)?;
1456            let var_file = source.path().join("30-var.conf");
1457            std::fs::write(&var_file, "[Partition]\nType=var\nFormat=ext4\n")?;
1458            let mut partitions: Vec<RepartPartition> = serde_json::from_value(serde_json::json!([
1459                { "type": "root-x86-64", "label": "custom-root", "file": root_file },
1460                { "type": "var", "file": var_file }
1461            ]))?;
1462            let esp_config =
1463                "[Partition]\nType=esp\nFormat=vfat\nLabel=custom-esp\nSizeMinBytes=512M\n";
1464            if layout == AbootDiskLayout::Ukiboot {
1465                let esp_file = source.path().join("05-esp.conf");
1466                std::fs::write(&esp_file, esp_config)?;
1467                partitions.push(serde_json::from_value(serde_json::json!({
1468                    "type": "esp", "file": esp_file
1469                }))?);
1470            }
1471
1472            validate_aboot_repart_partitions(&partitions, layout)?;
1473            let generated = aboot_repart_definitions(layout, Some(8192), None, &partitions)?;
1474            assert_eq!(generated.len(), aboot_partition_labels(layout).len());
1475            assert!(
1476                generated
1477                    .iter()
1478                    .all(|(_, config)| !config.contains("Format="))
1479            );
1480            collect_repart_definitions(&partitions, destination.path(), |part| {
1481                !generic_image
1482                    || repart_partition_is_required(part)
1483                    || aboot_partition_labels(layout).contains(&part.label.as_deref().unwrap_or(""))
1484            })?;
1485            assert_eq!(
1486                std::fs::read_to_string(destination.path().join("20-root.conf"))?,
1487                root_config
1488            );
1489            assert_eq!(
1490                destination.path().join("30-var.conf").exists(),
1491                !generic_image
1492            );
1493            if layout == AbootDiskLayout::Ukiboot {
1494                assert_eq!(
1495                    std::fs::read_to_string(destination.path().join("05-esp.conf"))?,
1496                    esp_config
1497                );
1498            }
1499            assert!(aboot_repart_definitions(layout, None, None, &[]).is_err());
1500        }
1501        Ok(())
1502    }
1503
1504    #[test]
1505    fn test_planned_root_size_mib() {
1506        // This module imports anyhow::Ok
1507        use std::result::Result::Ok;
1508        let cases = [
1509            // A dry run on a 10 GiB disk of the definitions of
1510            // tmt/tests/booted/test-install-repart.nu (ESP, home and swap,
1511            // 1 GiB each) plus the generated root without size limits, which
1512            // gets the rest minus the GPT and alignment overhead.
1513            (
1514                include_str!("../fixtures/repart-dry-run-v262.json"),
1515                Ok(7166),
1516            ),
1517            // A dry run without root, where systemd 262 reports the unused
1518            // space as padding of the last partition.
1519            (
1520                r#"[{"type": "swap", "file": "30-swap.conf", "raw_size": 1073741824, "raw_padding": 7515123712}]"#,
1521                Err("planned no root partition"),
1522            ),
1523            (
1524                r#"[{"type": "root-x86-64", "file": "50-root.conf", "raw_size": 1044480}]"#,
1525                Err("less than 1 MiB"),
1526            ),
1527            (
1528                r#"[{"type": "root-x86-64", "file": "50-root.conf"}]"#,
1529                Err("less than 1 MiB"),
1530            ),
1531        ];
1532        for (json, expected) in cases {
1533            let planned: Vec<RepartPartition> = serde_json::from_str(json).unwrap();
1534            match (planned_root_size_mib(&planned), expected) {
1535                (Ok(size_mib), Ok(expected)) => assert_eq!(size_mib.get(), expected),
1536                (Err(e), Err(expected)) => {
1537                    assert!(format!("{e:#}").contains(expected), "{e:#}")
1538                }
1539                (result, expected) => panic!("{json}: got {result:?}, expected {expected:?}"),
1540            }
1541        }
1542    }
1543
1544    #[test]
1545    fn test_planned_boot_partition_sizes() {
1546        let cases = [
1547            // systemd 262 names the definitions with their full path; only the
1548            // ESP is created by a generic install.
1549            (
1550                include_str!("../fixtures/repart-dry-run-v262.json"),
1551                vec![("00-esp.conf", 1073741824)],
1552            ),
1553            // systemd 252 names them without a path, and BIOS boot by its GUID.
1554            (
1555                r#"[
1556                    {"type": "21686148-6449-6e6f-744e-656564454649", "file": "00-bios.conf", "raw_size": 1048576},
1557                    {"type": "esp", "file": "00-esp.conf", "raw_size": 536870912},
1558                    {"type": "xbootldr", "file": "10-xbootldr.conf", "raw_size": 1073741824},
1559                    {"type": "root-x86-64", "file": "50-root.conf", "raw_size": 7516192768}
1560                ]"#,
1561                vec![("00-bios.conf", 1048576), ("00-esp.conf", 536870912)],
1562            ),
1563            (r#"[]"#, vec![]),
1564        ];
1565        for (json, expected) in cases {
1566            let planned: Vec<RepartPartition> = serde_json::from_str(json).unwrap();
1567            let sizes = planned_boot_partition_sizes(&planned).unwrap();
1568            let sizes = sizes
1569                .iter()
1570                .map(|(name, size)| (name.as_str(), *size))
1571                .collect::<Vec<_>>();
1572            assert_eq!(sizes, expected, "{json}");
1573        }
1574    }
1575
1576    #[test]
1577    fn test_repart_definitions_args() {
1578        use RepartDefinitions::{Image, Only, WithGenerated};
1579        let include = format!("--include-partitions=root,esp,{BIOS_BOOT}");
1580        let with_generated = [
1581            "--definitions=/etc/repart.d",
1582            "--definitions=/run/repart.d",
1583            "--definitions=/usr/local/lib/repart.d",
1584            "--definitions=/usr/lib/repart.d",
1585            "--definitions=/tmp/generated",
1586        ];
1587        let generated = WithGenerated(Path::new("/tmp/generated"));
1588        let copies = Only(Path::new("/tmp/copies"));
1589        let cases = [
1590            (Image, false, vec![]),
1591            (Image, true, vec![include.as_str()]),
1592            (generated, false, with_generated.to_vec()),
1593            (
1594                generated,
1595                true,
1596                [&with_generated[..], &[include.as_str()]].concat(),
1597            ),
1598            // Already just the definitions to act on
1599            (copies, false, vec!["--definitions=/tmp/copies"]),
1600            (copies, true, vec!["--definitions=/tmp/copies"]),
1601        ];
1602        for (definitions, generic_image, expected) in cases {
1603            let expected = expected.into_iter().map(OsString::from).collect::<Vec<_>>();
1604            assert_eq!(
1605                definitions.args(generic_image),
1606                expected,
1607                "{definitions:?}, generic image: {generic_image}"
1608            );
1609        }
1610    }
1611}