bootc_lib/
container_export.rs

1//! # Container Export Functionality
2//!
3//! This module implements the `bootc container export` command which exports
4//! container filesystems as bootable tar archives with proper SELinux labeling
5//! and legacy boot compatibility.
6
7use anyhow::{Context, Result};
8use camino::Utf8Path;
9use cap_std_ext::dirext::{CapStdExtDirExt, WalkConfiguration};
10use fn_error_context::context;
11use ostree_ext::ostree;
12use std::collections::HashMap;
13use std::fs::File;
14use std::io::{self, Write};
15use std::ops::ControlFlow;
16
17use crate::cli::ExportFormat;
18
19/// Options for container export.
20#[derive(Debug, Default)]
21struct ExportOptions {
22    /// Copy kernel and initramfs to /boot for legacy compatibility.
23    kernel_in_boot: bool,
24    /// Disable SELinux labeling.
25    disable_selinux: bool,
26}
27
28/// Export a container filesystem to tar format with bootc-specific features.
29#[context("Exporting container")]
30pub(crate) async fn export(
31    format: &ExportFormat,
32    target_path: &Utf8Path,
33    output_path: Option<&Utf8Path>,
34    kernel_in_boot: bool,
35    disable_selinux: bool,
36) -> Result<()> {
37    use cap_std_ext::cap_std;
38    use cap_std_ext::cap_std::fs::Dir;
39
40    let options = ExportOptions {
41        kernel_in_boot,
42        disable_selinux,
43    };
44
45    let root_dir = Dir::open_ambient_dir(target_path, cap_std::ambient_authority())
46        .with_context(|| format!("Failed to open directory: {}", target_path))?;
47
48    match format {
49        ExportFormat::Tar => export_tar(&root_dir, output_path, &options).await,
50    }
51}
52
53/// Export container filesystem as tar archive.
54#[context("Exporting to tar")]
55async fn export_tar(
56    root_dir: &cap_std_ext::cap_std::fs::Dir,
57    output_path: Option<&Utf8Path>,
58    options: &ExportOptions,
59) -> Result<()> {
60    let output: Box<dyn Write> = match output_path {
61        Some(path) => {
62            let file = File::create(path)
63                .with_context(|| format!("Failed to create output file: {}", path))?;
64            Box::new(file)
65        }
66        None => Box::new(io::stdout()),
67    };
68
69    let mut tar_builder = tar::Builder::new(output);
70    export_filesystem(&mut tar_builder, root_dir, options)?;
71    tar_builder.finish().context("Finalizing tar archive")?;
72
73    Ok(())
74}
75
76fn export_filesystem<W: Write>(
77    tar_builder: &mut tar::Builder<W>,
78    root_dir: &cap_std_ext::cap_std::fs::Dir,
79    options: &ExportOptions,
80) -> Result<()> {
81    // Load SELinux policy from the image filesystem.
82    // We use the policy to compute labels rather than reading xattrs from the
83    // mounted filesystem, because OCI images don't usually include selinux xattrs,
84    // and the mounted runtime will have e.g. container_t
85    let sepolicy = if options.disable_selinux {
86        None
87    } else {
88        crate::lsm::new_sepolicy_at(root_dir)?
89    };
90
91    export_filesystem_walk(tar_builder, root_dir, sepolicy.as_ref())?;
92
93    if options.kernel_in_boot {
94        handle_kernel_relocation(tar_builder, root_dir)?;
95    }
96
97    Ok(())
98}
99
100/// Create a tar header from filesystem metadata.
101fn tar_header_from_meta(
102    entry_type: tar::EntryType,
103    size: u64,
104    meta: &cap_std_ext::cap_std::fs::Metadata,
105) -> tar::Header {
106    use cap_std_ext::cap_primitives::fs::{MetadataExt, PermissionsExt};
107
108    let mut header = tar::Header::new_gnu();
109    header.set_entry_type(entry_type);
110    header.set_size(size);
111    header.set_mode(meta.permissions().mode() & !libc::S_IFMT);
112    header.set_uid(meta.uid() as u64);
113    header.set_gid(meta.gid() as u64);
114    header
115}
116
117/// Create a tar header for a root-owned directory with mode 0755.
118fn tar_header_dir_root() -> tar::Header {
119    let mut header = tar::Header::new_gnu();
120    header.set_entry_type(tar::EntryType::Directory);
121    header.set_size(0);
122    header.set_mode(0o755);
123    header.set_uid(0);
124    header.set_gid(0);
125    header
126}
127
128/// Paths that should be skipped during export.
129/// These are bootc/ostree-specific paths that shouldn't be in the exported tarball.
130const SKIP_PATHS: &[&str] = &["sysroot/ostree"];
131
132fn export_filesystem_walk<W: Write>(
133    tar_builder: &mut tar::Builder<W>,
134    root_dir: &cap_std_ext::cap_std::fs::Dir,
135    sepolicy: Option<&ostree::SePolicy>,
136) -> Result<()> {
137    use std::path::Path;
138
139    // Track hardlinks: maps (dev, inode) -> first path seen.
140    // We key on (dev, ino) because overlay filesystems may present
141    // different device numbers for directories vs regular files.
142    let mut hardlinks: HashMap<(u64, u64), std::path::PathBuf> = HashMap::new();
143
144    // The target mount shouldn't have submounts, but just in case we use noxdev
145    let walk_config = WalkConfiguration::default()
146        .noxdev()
147        .path_base(Path::new("/"));
148
149    root_dir.walk(&walk_config, |entry| -> std::io::Result<ControlFlow<()>> {
150        let path = entry.path;
151
152        // Skip the root directory itself - it is meaningless in OCI right now
153        // https://github.com/containers/composefs-rs/pull/209
154        // The root is represented as "/" which has one component
155        if path == Path::new("/") {
156            return Ok(ControlFlow::Continue(()));
157        }
158
159        // Ensure the path is relative by default
160        let relative_path = path.strip_prefix("/").unwrap_or(path);
161
162        // Skip empty paths (shouldn't happen but be safe)
163        if relative_path == Path::new("") {
164            return Ok(ControlFlow::Continue(()));
165        }
166
167        // Skip paths that shouldn't be in the exported tarball
168        for skip_path in SKIP_PATHS {
169            if relative_path.starts_with(skip_path) {
170                // For directories, skip the entire subtree
171                if entry.file_type.is_dir() {
172                    return Ok(ControlFlow::Break(()));
173                }
174                return Ok(ControlFlow::Continue(()));
175            }
176        }
177
178        let file_type = entry.file_type;
179        if file_type.is_dir() {
180            add_directory_to_tar_from_walk(tar_builder, entry.dir, path, relative_path, sepolicy)
181                .map_err(std::io::Error::other)?;
182        } else if file_type.is_file() {
183            add_file_to_tar_from_walk(
184                tar_builder,
185                entry.dir,
186                entry.filename,
187                path,
188                relative_path,
189                sepolicy,
190                &mut hardlinks,
191            )
192            .map_err(std::io::Error::other)?;
193        } else if file_type.is_symlink() {
194            add_symlink_to_tar_from_walk(
195                tar_builder,
196                entry.dir,
197                entry.filename,
198                path,
199                relative_path,
200                sepolicy,
201            )
202            .map_err(std::io::Error::other)?;
203        } else {
204            return Err(std::io::Error::other(format!(
205                "Unsupported file type: {}",
206                relative_path.display()
207            )));
208        }
209
210        Ok(ControlFlow::Continue(()))
211    })?;
212
213    Ok(())
214}
215
216fn add_directory_to_tar_from_walk<W: Write>(
217    tar_builder: &mut tar::Builder<W>,
218    dir: &cap_std_ext::cap_std::fs::Dir,
219    absolute_path: &std::path::Path,
220    relative_path: &std::path::Path,
221    sepolicy: Option<&ostree::SePolicy>,
222) -> Result<()> {
223    use cap_std_ext::cap_primitives::fs::PermissionsExt;
224
225    let metadata = dir.dir_metadata()?;
226    let mut header = tar_header_from_meta(tar::EntryType::Directory, 0, &metadata);
227
228    if let Some(policy) = sepolicy {
229        let label = compute_selinux_label(policy, absolute_path, metadata.permissions().mode())?;
230        add_selinux_pax_extension(tar_builder, &label)?;
231    }
232
233    tar_builder
234        .append_data(&mut header, relative_path, &mut std::io::empty())
235        .with_context(|| format!("Failed to add directory: {}", relative_path.display()))?;
236
237    Ok(())
238}
239
240fn add_file_to_tar_from_walk<W: Write>(
241    tar_builder: &mut tar::Builder<W>,
242    dir: &cap_std_ext::cap_std::fs::Dir,
243    filename: &std::ffi::OsStr,
244    absolute_path: &std::path::Path,
245    relative_path: &std::path::Path,
246    sepolicy: Option<&ostree::SePolicy>,
247    hardlinks: &mut HashMap<(u64, u64), std::path::PathBuf>,
248) -> Result<()> {
249    use cap_std_ext::cap_primitives::fs::{MetadataExt, PermissionsExt};
250    use std::path::Path;
251
252    let filename_path = Path::new(filename);
253    let metadata = dir.metadata(filename_path)?;
254
255    // Check for hardlinks: if nlink > 1, this file may have other links.
256    // We key on (dev, ino) because overlay filesystems may present
257    // different device numbers for directories vs regular files.
258    let nlink = metadata.nlink();
259    if nlink > 1 {
260        let key = (metadata.dev(), metadata.ino());
261        if let Some(first_path) = hardlinks.get(&key) {
262            // This is a hardlink to a file we've already written
263            let mut header = tar_header_from_meta(tar::EntryType::Link, 0, &metadata);
264
265            if let Some(policy) = sepolicy {
266                let label =
267                    compute_selinux_label(policy, absolute_path, metadata.permissions().mode())?;
268                add_selinux_pax_extension(tar_builder, &label)?;
269            }
270
271            tar_builder
272                .append_link(&mut header, relative_path, first_path)
273                .with_context(|| format!("Failed to add hardlink: {}", relative_path.display()))?;
274            return Ok(());
275        } else {
276            // First time seeing this inode, record it
277            hardlinks.insert(key, relative_path.to_path_buf());
278        }
279    }
280
281    // Regular file (or first occurrence of a hardlinked file)
282    let mut header = tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
283
284    if let Some(policy) = sepolicy {
285        let label = compute_selinux_label(policy, absolute_path, metadata.permissions().mode())?;
286        add_selinux_pax_extension(tar_builder, &label)?;
287    }
288
289    let mut file = dir.open(filename_path)?;
290    tar_builder
291        .append_data(&mut header, relative_path, &mut file)
292        .with_context(|| format!("Failed to add file: {}", relative_path.display()))?;
293
294    Ok(())
295}
296
297fn add_symlink_to_tar_from_walk<W: Write>(
298    tar_builder: &mut tar::Builder<W>,
299    dir: &cap_std_ext::cap_std::fs::Dir,
300    filename: &std::ffi::OsStr,
301    absolute_path: &std::path::Path,
302    relative_path: &std::path::Path,
303    sepolicy: Option<&ostree::SePolicy>,
304) -> Result<()> {
305    use cap_std_ext::cap_primitives::fs::PermissionsExt;
306    use std::path::Path;
307
308    let filename_path = Path::new(filename);
309    let link_target = dir
310        .read_link_contents(filename_path)
311        .with_context(|| format!("Failed to read symlink: {:?}", filename))?;
312    let metadata = dir.symlink_metadata(filename_path)?;
313    let mut header = tar_header_from_meta(tar::EntryType::Symlink, 0, &metadata);
314
315    if let Some(policy) = sepolicy {
316        // For symlinks, combine S_IFLNK with mode for proper label lookup
317        let symlink_mode = libc::S_IFLNK | (metadata.permissions().mode() & !libc::S_IFMT);
318        let label = compute_selinux_label(policy, absolute_path, symlink_mode)?;
319        add_selinux_pax_extension(tar_builder, &label)?;
320    }
321
322    tar_builder
323        .append_link(&mut header, relative_path, &link_target)
324        .with_context(|| format!("Failed to add symlink: {}", relative_path.display()))?;
325
326    Ok(())
327}
328
329/// Copy kernel and initramfs to /boot for legacy installers (e.g. Anaconda liveimg).
330fn handle_kernel_relocation<W: Write>(
331    tar_builder: &mut tar::Builder<W>,
332    root_dir: &cap_std_ext::cap_std::fs::Dir,
333) -> Result<()> {
334    use crate::kernel::KernelType;
335
336    let kernel_info = match crate::kernel::find_kernel(root_dir)? {
337        Some(kernel) => kernel,
338        None => return Ok(()),
339    };
340
341    append_dir_entry(tar_builder, "boot")?;
342    append_dir_entry(tar_builder, "boot/grub2")?;
343
344    // UKIs don't need relocation - they're already in /boot/EFI/Linux
345    if kernel_info.kernel.unified {
346        return Ok(());
347    }
348
349    // Traditional vmlinuz kernels need to be copied to /boot
350    if let KernelType::Vmlinuz { path, initramfs } = &kernel_info.k_type {
351        let version = &kernel_info.kernel.version;
352
353        // Copy vmlinuz
354        if root_dir.try_exists(path)? {
355            let metadata = root_dir.metadata(path)?;
356            let mut header =
357                tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
358            let mut file = root_dir.open(path)?;
359            let boot_path = format!("boot/vmlinuz-{}", version);
360            tar_builder
361                .append_data(&mut header, &boot_path, &mut file)
362                .with_context(|| format!("Failed to add kernel: {}", boot_path))?;
363        }
364
365        // Copy initramfs
366        if root_dir.try_exists(initramfs)? {
367            let metadata = root_dir.metadata(initramfs)?;
368            let mut header =
369                tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
370            let mut file = root_dir.open(initramfs)?;
371            let boot_path = format!("boot/initramfs-{}.img", version);
372            tar_builder
373                .append_data(&mut header, &boot_path, &mut file)
374                .with_context(|| format!("Failed to add initramfs: {}", boot_path))?;
375        }
376    }
377
378    Ok(())
379}
380
381fn append_dir_entry<W: Write>(tar_builder: &mut tar::Builder<W>, path: &str) -> Result<()> {
382    let mut header = tar_header_dir_root();
383    tar_builder
384        .append_data(&mut header, path, &mut std::io::empty())
385        .with_context(|| format!("Failed to create {} directory", path))?;
386    Ok(())
387}
388
389fn compute_selinux_label(
390    policy: &ostree::SePolicy,
391    path: &std::path::Path,
392    mode: u32,
393) -> Result<String> {
394    use camino::Utf8Path;
395
396    // Convert path to UTF-8 for policy lookup - non-UTF8 paths are not supported
397    let path_str = path
398        .to_str()
399        .ok_or_else(|| anyhow::anyhow!("Non-UTF8 path not supported: {:?}", path))?;
400    let utf8_path = Utf8Path::new(path_str);
401
402    let label = crate::lsm::require_label(policy, utf8_path, mode)?;
403    Ok(label.to_string())
404}
405
406fn add_selinux_pax_extension<W: Write>(
407    tar_builder: &mut tar::Builder<W>,
408    selinux_context: &str,
409) -> Result<()> {
410    tar_builder
411        .append_pax_extensions([("SCHILY.xattr.security.selinux", selinux_context.as_bytes())])
412        .context("Failed to add SELinux PAX extension")?;
413    Ok(())
414}