Skip to main content

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, ExportSelinuxMode};
18
19/// Options for container export.
20#[derive(Debug)]
21struct ExportOptions<'a> {
22    kernel_in_boot: bool,
23    selinux: &'a ExportSelinuxMode,
24}
25
26/// Export a container filesystem to tar format with bootc-specific features.
27#[context("Exporting container")]
28pub(crate) async fn export(
29    format: &ExportFormat,
30    target_path: &Utf8Path,
31    output_path: Option<&Utf8Path>,
32    kernel_in_boot: bool,
33    selinux: &ExportSelinuxMode,
34) -> Result<()> {
35    use cap_std_ext::cap_std;
36    use cap_std_ext::cap_std::fs::Dir;
37
38    let options = ExportOptions {
39        kernel_in_boot,
40        selinux,
41    };
42
43    let root_dir = Dir::open_ambient_dir(target_path, cap_std::ambient_authority())
44        .with_context(|| format!("Failed to open directory: {}", target_path))?;
45
46    match format {
47        ExportFormat::Tar => export_tar(&root_dir, output_path, &options).await,
48    }
49}
50
51/// Export container filesystem as tar archive.
52#[context("Exporting to tar")]
53async fn export_tar(
54    root_dir: &cap_std_ext::cap_std::fs::Dir,
55    output_path: Option<&Utf8Path>,
56    options: &ExportOptions<'_>,
57) -> Result<()> {
58    let output: Box<dyn Write> = match output_path {
59        Some(path) => {
60            let file = File::create(path)
61                .with_context(|| format!("Failed to create output file: {}", path))?;
62            Box::new(file)
63        }
64        None => Box::new(io::stdout()),
65    };
66
67    let mut tar_builder = tar::Builder::new(output);
68    export_filesystem(&mut tar_builder, root_dir, options)?;
69    tar_builder.finish().context("Finalizing tar archive")?;
70
71    Ok(())
72}
73
74/// How to handle SELinux labeling during export.
75enum SepolicyState {
76    /// SELinux labeling is disabled (--selinux=disabled).
77    Disabled,
78    /// Missing labels are a hard error (--selinux=enabled, default).
79    Required(ostree::SePolicy),
80    /// Missing labels emit a warning and export continues (--selinux=warn-on-missing).
81    WarnOnMissing(ostree::SePolicy),
82}
83
84fn export_filesystem<W: Write>(
85    tar_builder: &mut tar::Builder<W>,
86    root_dir: &cap_std_ext::cap_std::fs::Dir,
87    options: &ExportOptions<'_>,
88) -> Result<()> {
89    // Load SELinux policy from the image filesystem.
90    // We use the policy to compute labels rather than reading xattrs from the
91    // mounted filesystem, because OCI images don't usually include selinux xattrs,
92    // and the mounted runtime will have e.g. container_t
93    let sepolicy_state = if *options.selinux == ExportSelinuxMode::Disabled {
94        SepolicyState::Disabled
95    } else {
96        match crate::lsm::new_sepolicy_at(root_dir)? {
97            Some(policy) => match options.selinux {
98                ExportSelinuxMode::Enabled => SepolicyState::Required(policy),
99                ExportSelinuxMode::WarnOnMissing => SepolicyState::WarnOnMissing(policy),
100                ExportSelinuxMode::Disabled => unreachable!(),
101            },
102            None => {
103                tracing::warn!("SELinux labeling requested but no policy found in image");
104                SepolicyState::Disabled
105            }
106        }
107    };
108
109    let mut unlabeled_count = 0u64;
110    export_filesystem_walk(tar_builder, root_dir, &sepolicy_state, &mut unlabeled_count)?;
111
112    if unlabeled_count > 0 {
113        tracing::warn!(
114            "{unlabeled_count} file(s) exported without SELinux labels (no policy match)"
115        );
116    }
117
118    if options.kernel_in_boot {
119        handle_kernel_relocation(tar_builder, root_dir)?;
120    }
121
122    Ok(())
123}
124
125/// Create a tar header from filesystem metadata.
126fn tar_header_from_meta(
127    entry_type: tar::EntryType,
128    size: u64,
129    meta: &cap_std_ext::cap_std::fs::Metadata,
130) -> tar::Header {
131    use cap_std_ext::cap_primitives::fs::{MetadataExt, PermissionsExt};
132
133    let mut header = tar::Header::new_gnu();
134    header.set_entry_type(entry_type);
135    header.set_size(size);
136    header.set_mode(meta.permissions().mode() & !libc::S_IFMT);
137    header.set_uid(meta.uid() as u64);
138    header.set_gid(meta.gid() as u64);
139    header
140}
141
142/// Create a tar header for a root-owned directory with mode 0755.
143fn tar_header_dir_root() -> tar::Header {
144    let mut header = tar::Header::new_gnu();
145    header.set_entry_type(tar::EntryType::Directory);
146    header.set_size(0);
147    header.set_mode(0o755);
148    header.set_uid(0);
149    header.set_gid(0);
150    header
151}
152
153/// Paths that should be skipped during export.
154/// - `sysroot/ostree` is bootc/ostree-specific and shouldn't be in the exported tarball.
155/// - `tmp` and `var/tmp` are meant to hold only ephemeral, runtime-created content (the
156///   same paths `ostree-ext::commit` always cleans before committing). They can end up
157///   containing arbitrary files dropped by package post-install scripts (e.g. `rhc`)
158///   that the SELinux policy has no file-context entry for, which would otherwise turn
159///   into a hard failure when computing labels for the tar entries.
160const SKIP_PATHS: &[&str] = &["sysroot/ostree", "tmp", "var/tmp"];
161
162fn export_filesystem_walk<W: Write>(
163    tar_builder: &mut tar::Builder<W>,
164    root_dir: &cap_std_ext::cap_std::fs::Dir,
165    sepolicy_state: &SepolicyState,
166    unlabeled_count: &mut u64,
167) -> Result<()> {
168    use std::path::Path;
169
170    // Track hardlinks: maps (dev, inode) -> first path seen.
171    // We key on (dev, ino) because overlay filesystems may present
172    // different device numbers for directories vs regular files.
173    let mut hardlinks: HashMap<(u64, u64), std::path::PathBuf> = HashMap::new();
174
175    // The target mount shouldn't have submounts, but just in case we use noxdev
176    let walk_config = WalkConfiguration::default()
177        .noxdev()
178        .path_base(Path::new("/"));
179
180    root_dir.walk(&walk_config, |entry| -> std::io::Result<ControlFlow<()>> {
181        let path = entry.path;
182
183        // Skip the root directory itself - it is meaningless in OCI right now
184        // https://github.com/containers/composefs-rs/pull/209
185        // The root is represented as "/" which has one component
186        if path == Path::new("/") {
187            return Ok(ControlFlow::Continue(()));
188        }
189
190        // Ensure the path is relative by default
191        let relative_path = path.strip_prefix("/").unwrap_or(path);
192
193        // Skip empty paths (shouldn't happen but be safe)
194        if relative_path == Path::new("") {
195            return Ok(ControlFlow::Continue(()));
196        }
197
198        // Skip paths that shouldn't be in the exported tarball
199        for skip_path in SKIP_PATHS {
200            if relative_path.starts_with(skip_path) {
201                // For directories, skip the entire subtree
202                if entry.file_type.is_dir() {
203                    return Ok(ControlFlow::Break(()));
204                }
205                return Ok(ControlFlow::Continue(()));
206            }
207        }
208
209        let file_type = entry.file_type;
210        if file_type.is_dir() {
211            add_directory_to_tar_from_walk(
212                tar_builder,
213                entry.entry,
214                path,
215                relative_path,
216                sepolicy_state,
217                unlabeled_count,
218            )
219            .map_err(std::io::Error::other)?;
220        } else if file_type.is_file() {
221            add_file_to_tar_from_walk(
222                tar_builder,
223                entry.dir,
224                entry.filename,
225                path,
226                relative_path,
227                sepolicy_state,
228                unlabeled_count,
229                &mut hardlinks,
230            )
231            .map_err(std::io::Error::other)?;
232        } else if file_type.is_symlink() {
233            add_symlink_to_tar_from_walk(
234                tar_builder,
235                entry.dir,
236                entry.filename,
237                path,
238                relative_path,
239                sepolicy_state,
240                unlabeled_count,
241            )
242            .map_err(std::io::Error::other)?;
243        } else {
244            return Err(std::io::Error::other(format!(
245                "Unsupported file type: {}",
246                relative_path.display()
247            )));
248        }
249
250        Ok(ControlFlow::Continue(()))
251    })?;
252
253    Ok(())
254}
255
256fn add_directory_to_tar_from_walk<W: Write>(
257    tar_builder: &mut tar::Builder<W>,
258    entry: &cap_std_ext::cap_std::fs::DirEntry,
259    absolute_path: &std::path::Path,
260    relative_path: &std::path::Path,
261    sepolicy_state: &SepolicyState,
262    unlabeled_count: &mut u64,
263) -> Result<()> {
264    use cap_std_ext::cap_primitives::fs::PermissionsExt;
265
266    let metadata = entry.metadata().with_context(|| {
267        format!(
268            "Failed to read directory metadata: {}",
269            relative_path.display()
270        )
271    })?;
272    let mut header = tar_header_from_meta(tar::EntryType::Directory, 0, &metadata);
273
274    maybe_add_selinux_label(
275        tar_builder,
276        sepolicy_state,
277        absolute_path,
278        metadata.permissions().mode(),
279        unlabeled_count,
280    )?;
281
282    tar_builder
283        .append_data(&mut header, relative_path, &mut std::io::empty())
284        .with_context(|| format!("Failed to add directory: {}", relative_path.display()))?;
285
286    Ok(())
287}
288
289fn add_file_to_tar_from_walk<W: Write>(
290    tar_builder: &mut tar::Builder<W>,
291    dir: &cap_std_ext::cap_std::fs::Dir,
292    filename: &std::ffi::OsStr,
293    absolute_path: &std::path::Path,
294    relative_path: &std::path::Path,
295    sepolicy_state: &SepolicyState,
296    unlabeled_count: &mut u64,
297    hardlinks: &mut HashMap<(u64, u64), std::path::PathBuf>,
298) -> Result<()> {
299    use cap_std_ext::cap_primitives::fs::{MetadataExt, PermissionsExt};
300    use std::path::Path;
301
302    let filename_path = Path::new(filename);
303    let metadata = dir.metadata(filename_path)?;
304
305    // Check for hardlinks: if nlink > 1, this file may have other links.
306    // We key on (dev, ino) because overlay filesystems may present
307    // different device numbers for directories vs regular files.
308    let nlink = metadata.nlink();
309    if nlink > 1 {
310        let key = (metadata.dev(), metadata.ino());
311        if let Some(first_path) = hardlinks.get(&key) {
312            let mut header = tar_header_from_meta(tar::EntryType::Link, 0, &metadata);
313
314            maybe_add_selinux_label(
315                tar_builder,
316                sepolicy_state,
317                absolute_path,
318                metadata.permissions().mode(),
319                unlabeled_count,
320            )?;
321
322            tar_builder
323                .append_link(&mut header, relative_path, first_path)
324                .with_context(|| format!("Failed to add hardlink: {}", relative_path.display()))?;
325            return Ok(());
326        } else {
327            hardlinks.insert(key, relative_path.to_path_buf());
328        }
329    }
330
331    let mut header = tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
332
333    maybe_add_selinux_label(
334        tar_builder,
335        sepolicy_state,
336        absolute_path,
337        metadata.permissions().mode(),
338        unlabeled_count,
339    )?;
340
341    let mut file = dir.open(filename_path)?;
342    tar_builder
343        .append_data(&mut header, relative_path, &mut file)
344        .with_context(|| format!("Failed to add file: {}", relative_path.display()))?;
345
346    Ok(())
347}
348
349fn add_symlink_to_tar_from_walk<W: Write>(
350    tar_builder: &mut tar::Builder<W>,
351    dir: &cap_std_ext::cap_std::fs::Dir,
352    filename: &std::ffi::OsStr,
353    absolute_path: &std::path::Path,
354    relative_path: &std::path::Path,
355    sepolicy_state: &SepolicyState,
356    unlabeled_count: &mut u64,
357) -> Result<()> {
358    use cap_std_ext::cap_primitives::fs::PermissionsExt;
359    use std::path::Path;
360
361    let filename_path = Path::new(filename);
362    let link_target = dir
363        .read_link_contents(filename_path)
364        .with_context(|| format!("Failed to read symlink: {:?}", filename))?;
365    let metadata = dir.symlink_metadata(filename_path)?;
366    let mut header = tar_header_from_meta(tar::EntryType::Symlink, 0, &metadata);
367
368    let symlink_mode = libc::S_IFLNK | (metadata.permissions().mode() & !libc::S_IFMT);
369    maybe_add_selinux_label(
370        tar_builder,
371        sepolicy_state,
372        absolute_path,
373        symlink_mode,
374        unlabeled_count,
375    )?;
376
377    tar_builder
378        .append_link(&mut header, relative_path, &link_target)
379        .with_context(|| format!("Failed to add symlink: {}", relative_path.display()))?;
380
381    Ok(())
382}
383
384/// Copy kernel and initramfs to /boot for legacy installers (e.g. Anaconda liveimg).
385fn handle_kernel_relocation<W: Write>(
386    tar_builder: &mut tar::Builder<W>,
387    root_dir: &cap_std_ext::cap_std::fs::Dir,
388) -> Result<()> {
389    use crate::kernel::KernelType;
390
391    let kernel_info = match crate::kernel::find_kernel(root_dir, None)? {
392        Some(kernel) => kernel,
393        None => return Ok(()),
394    };
395
396    append_dir_entry(tar_builder, "boot")?;
397    append_dir_entry(tar_builder, "boot/grub2")?;
398
399    // UKIs and aboot payloads don't need relocation - they're already in /boot.
400    if kernel_info.k_type.is_unified() {
401        return Ok(());
402    }
403
404    // Traditional vmlinuz kernels need to be copied to /boot
405    if let KernelType::Vmlinuz { path, initramfs } = &kernel_info.k_type {
406        let version = &kernel_info.kernel.version;
407
408        // Copy vmlinuz
409        if root_dir.try_exists(path)? {
410            let metadata = root_dir.metadata(path)?;
411            let mut header =
412                tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
413            let mut file = root_dir.open(path)?;
414            let boot_path = format!("boot/vmlinuz-{}", version);
415            tar_builder
416                .append_data(&mut header, &boot_path, &mut file)
417                .with_context(|| format!("Failed to add kernel: {}", boot_path))?;
418        }
419
420        // Copy initramfs
421        if root_dir.try_exists(initramfs)? {
422            let metadata = root_dir.metadata(initramfs)?;
423            let mut header =
424                tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
425            let mut file = root_dir.open(initramfs)?;
426            let boot_path = format!("boot/initramfs-{}.img", version);
427            tar_builder
428                .append_data(&mut header, &boot_path, &mut file)
429                .with_context(|| format!("Failed to add initramfs: {}", boot_path))?;
430        }
431    }
432
433    Ok(())
434}
435
436fn append_dir_entry<W: Write>(tar_builder: &mut tar::Builder<W>, path: &str) -> Result<()> {
437    let mut header = tar_header_dir_root();
438    tar_builder
439        .append_data(&mut header, path, &mut std::io::empty())
440        .with_context(|| format!("Failed to create {} directory", path))?;
441    Ok(())
442}
443
444fn maybe_add_selinux_label<W: Write>(
445    tar_builder: &mut tar::Builder<W>,
446    sepolicy_state: &SepolicyState,
447    absolute_path: &std::path::Path,
448    mode: u32,
449    unlabeled_count: &mut u64,
450) -> Result<()> {
451    let (policy, warn) = match sepolicy_state {
452        SepolicyState::Disabled => return Ok(()),
453        SepolicyState::Required(p) => (p, false),
454        SepolicyState::WarnOnMissing(p) => (p, true),
455    };
456
457    let path_str = absolute_path
458        .to_str()
459        .ok_or_else(|| anyhow::anyhow!("Non-UTF8 path not supported: {:?}", absolute_path))?;
460    let utf8_path = Utf8Path::new(path_str);
461
462    match crate::lsm::optional_label(policy, utf8_path, mode)? {
463        Some(label) if !label.is_empty() => {
464            add_selinux_pax_extension(tar_builder, &label)?;
465        }
466        _ if !warn => {
467            anyhow::bail!(
468                "No label found in policy '{:?}' for {}",
469                policy.csum(),
470                absolute_path.display()
471            );
472        }
473        _ => {
474            *unlabeled_count += 1;
475            tracing::debug!("No SELinux label for: {}", absolute_path.display());
476        }
477    }
478
479    Ok(())
480}
481
482fn add_selinux_pax_extension<W: Write>(
483    tar_builder: &mut tar::Builder<W>,
484    selinux_context: &str,
485) -> Result<()> {
486    tar_builder
487        .append_pax_extensions([("SCHILY.xattr.security.selinux", selinux_context.as_bytes())])
488        .context("Failed to add SELinux PAX extension")?;
489    Ok(())
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use cap_std_ext::cap_std::{ambient_authority, fs::Dir};
496    use std::os::unix::fs::PermissionsExt;
497
498    /// Walk `root` (with SELinux labeling disabled) and return the set of
499    /// relative paths that ended up in the resulting tar archive.
500    fn exported_paths(root: &std::path::Path) -> Result<std::collections::BTreeSet<String>> {
501        let dir = Dir::open_ambient_dir(root, ambient_authority())?;
502        let mut buf = Vec::new();
503        {
504            let mut tar_builder = tar::Builder::new(&mut buf);
505            let mut unlabeled_count = 0u64;
506            export_filesystem_walk(
507                &mut tar_builder,
508                &dir,
509                &SepolicyState::Disabled,
510                &mut unlabeled_count,
511            )?;
512            tar_builder.finish()?;
513        }
514        tar::Archive::new(buf.as_slice())
515            .entries()?
516            .map(|e| Ok(e?.path()?.to_string_lossy().into_owned()))
517            .collect()
518    }
519
520    /// Walk `root` (with SELinux labeling disabled) and return modes for tar directories.
521    fn exported_directory_modes(
522        root: &std::path::Path,
523    ) -> Result<std::collections::BTreeMap<String, u32>> {
524        let dir = Dir::open_ambient_dir(root, ambient_authority())?;
525        let mut buf = Vec::new();
526        {
527            let mut tar_builder = tar::Builder::new(&mut buf);
528            export_filesystem_walk(&mut tar_builder, &dir, &SepolicyState::Disabled, &mut 0)?;
529            tar_builder.finish()?;
530        }
531
532        let mut modes = std::collections::BTreeMap::new();
533        for entry in tar::Archive::new(buf.as_slice()).entries()? {
534            let entry = entry?;
535            if entry.header().entry_type() != tar::EntryType::Directory {
536                continue;
537            }
538            let path = entry
539                .path()?
540                .to_string_lossy()
541                .trim_end_matches('/')
542                .to_owned();
543            modes.insert(path, entry.header().mode()? & 0o7777);
544        }
545        Ok(modes)
546    }
547
548    #[test]
549    fn test_export_skips_tmp_and_var_tmp() -> Result<()> {
550        let tmpdir = tempfile::tempdir()?;
551        let root = tmpdir.path();
552
553        // Content that must be skipped, including a stand-in for the
554        // `/var/tmp/rhc` file dropped by package post-install scripts that
555        // the SELinux policy has no file-context entry for.
556        std::fs::create_dir_all(root.join("tmp/nested"))?;
557        std::fs::write(root.join("tmp/nested/junk"), b"junk")?;
558        std::fs::create_dir_all(root.join("var/tmp"))?;
559        std::fs::write(root.join("var/tmp/rhc"), b"rhc-state")?;
560
561        // Content that must be preserved.
562        std::fs::create_dir_all(root.join("usr/bin"))?;
563        std::fs::write(root.join("usr/bin/keep-me"), b"binary")?;
564        std::fs::create_dir_all(root.join("var/lib"))?;
565        std::fs::write(root.join("var/lib/keep-me-too"), b"state")?;
566
567        let paths = exported_paths(root)?;
568
569        assert!(paths.contains("usr/bin/keep-me"));
570        assert!(paths.contains("var/lib/keep-me-too"));
571        assert!(
572            !paths.iter().any(|p| p == "tmp" || p.starts_with("tmp/")),
573            "expected no /tmp entries, got: {paths:?}"
574        );
575        assert!(
576            !paths
577                .iter()
578                .any(|p| p == "var/tmp" || p.starts_with("var/tmp/")),
579            "expected no /var/tmp entries, got: {paths:?}"
580        );
581
582        Ok(())
583    }
584
585    #[test]
586    fn test_export_preserves_directory_modes() -> Result<()> {
587        let tmpdir = tempfile::tempdir()?;
588        let root = tmpdir.path();
589        let directories = [
590            ("var", 0o2750),
591            ("var/lib", 0o0711),
592            ("var/lib/nested", 0o0751),
593            ("var/cache", 0o0700),
594        ];
595
596        for (path, mode) in directories {
597            let path = root.join(path);
598            std::fs::create_dir_all(&path)?;
599            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
600        }
601
602        let modes = exported_directory_modes(root)?;
603        for (path, expected) in directories {
604            assert_eq!(modes.get(path), Some(&expected), "mode for {path}");
605        }
606
607        Ok(())
608    }
609}