1use 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#[derive(Debug)]
21struct ExportOptions<'a> {
22 kernel_in_boot: bool,
23 selinux: &'a ExportSelinuxMode,
24}
25
26#[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#[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
74enum SepolicyState {
76 Disabled,
78 Required(ostree::SePolicy),
80 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 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
125fn 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
142fn 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
153const 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 let mut hardlinks: HashMap<(u64, u64), std::path::PathBuf> = HashMap::new();
174
175 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 if path == Path::new("/") {
187 return Ok(ControlFlow::Continue(()));
188 }
189
190 let relative_path = path.strip_prefix("/").unwrap_or(path);
192
193 if relative_path == Path::new("") {
195 return Ok(ControlFlow::Continue(()));
196 }
197
198 for skip_path in SKIP_PATHS {
200 if relative_path.starts_with(skip_path) {
201 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 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
384fn 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 if kernel_info.k_type.is_unified() {
401 return Ok(());
402 }
403
404 if let KernelType::Vmlinuz { path, initramfs } = &kernel_info.k_type {
406 let version = &kernel_info.kernel.version;
407
408 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 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 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 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 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 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}