use std::{
fmt,
path::{Component, Path, PathBuf},
str::FromStr,
};
use anyhow::{Context, Result, anyhow};
use fs_err as fs;
use crate::libguestfs::{AddDriveOptArgs, Handle};
#[cfg(feature = "bsd")]
pub mod bsd;
mod libguestfs;
pub mod linux;
const DEFAULT_GUESTFS_DISK_FORMAT: &str = "qcow2";
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GuestfsMountMode {
Inspect,
Manual(Vec<String>),
}
impl Default for GuestfsMountMode {
fn default() -> Self {
Self::Inspect
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GuestfsDiskFormat {
Auto,
Named(String),
}
impl GuestfsDiskFormat {
fn as_guestfs_format(&self) -> Option<&str> {
match self {
Self::Auto => None,
Self::Named(format) => Some(format),
}
}
}
impl Default for GuestfsDiskFormat {
fn default() -> Self {
Self::Named(DEFAULT_GUESTFS_DISK_FORMAT.to_owned())
}
}
impl fmt::Display for GuestfsDiskFormat {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => formatter.write_str("auto"),
Self::Named(format) => formatter.write_str(format),
}
}
}
impl FromStr for GuestfsDiskFormat {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
let value = value.trim();
if value.is_empty() {
return Err("disk image format must not be empty".to_owned());
}
if value.eq_ignore_ascii_case("auto") {
return Ok(Self::Auto);
}
Ok(Self::Named(value.to_owned()))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct CopySpec {
pub guest_path: &'static str,
pub local_parent: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CopyPath {
guest_path: String,
local_parent: PathBuf,
required: bool,
}
impl CopyPath {
fn new(
guest_path: impl Into<String>,
local_parent: impl Into<PathBuf>,
required: bool,
) -> Self {
Self {
guest_path: guest_path.into(),
local_parent: local_parent.into(),
required,
}
}
}
#[derive(Clone, Debug)]
pub struct GuestfsCopyOptions {
disk_image: PathBuf,
copy_paths: Vec<CopyPath>,
disk_format: GuestfsDiskFormat,
mount_mode: GuestfsMountMode,
}
impl GuestfsCopyOptions {
pub fn new<I, S>(disk_image: impl Into<PathBuf>, guest_paths: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
disk_image: disk_image.into(),
copy_paths: Vec::from_iter(
guest_paths
.into_iter()
.map(|guest_path| CopyPath::new(guest_path, "", true)),
),
disk_format: GuestfsDiskFormat::default(),
mount_mode: GuestfsMountMode::Inspect,
}
}
pub fn with_disk_format(mut self, disk_format: GuestfsDiskFormat) -> Self {
self.disk_format = disk_format;
self
}
pub fn with_mount_mode(mut self, mount_mode: GuestfsMountMode) -> Self {
self.mount_mode = mount_mode;
self
}
}
pub(crate) fn sysroot_copy_options(
disk_image: impl Into<PathBuf>,
required: &[CopySpec],
optional: &[CopySpec],
) -> GuestfsCopyOptions {
GuestfsCopyOptions {
disk_image: disk_image.into(),
copy_paths: Vec::from_iter(
required
.iter()
.map(|spec| CopyPath::new(spec.guest_path, spec.local_parent, true))
.chain(
optional
.iter()
.map(|spec| CopyPath::new(spec.guest_path, spec.local_parent, false)),
),
),
disk_format: GuestfsDiskFormat::default(),
mount_mode: GuestfsMountMode::Inspect,
}
}
#[derive(Clone, Debug)]
pub struct SysrootOptions {
root_image: PathBuf,
guest_target: String,
output_parent: PathBuf,
disk_format: GuestfsDiskFormat,
mount_mode: GuestfsMountMode,
force: bool,
}
impl SysrootOptions {
pub fn new(
root_image: impl Into<PathBuf>,
guest_target: impl Into<String>,
output_parent: impl Into<PathBuf>,
) -> Self {
Self {
root_image: root_image.into(),
guest_target: guest_target.into(),
output_parent: output_parent.into(),
disk_format: GuestfsDiskFormat::default(),
mount_mode: GuestfsMountMode::Inspect,
force: false,
}
}
pub fn with_disk_format(mut self, disk_format: GuestfsDiskFormat) -> Self {
self.disk_format = disk_format;
self
}
pub fn with_mount_mode(mut self, mount_mode: GuestfsMountMode) -> Self {
self.mount_mode = mount_mode;
self
}
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
}
pub fn copy_paths_from_guest(
options: GuestfsCopyOptions,
destination: impl AsRef<Path>,
) -> Result<()> {
let destination = destination.as_ref();
validate_guestfs_copy_options(&options)?;
fs::create_dir_all(destination)
.with_context(|| format!("create guest copy destination '{}'", destination.display()))?;
copy_paths_with_libguestfs(&options, destination)
}
fn validate_guestfs_copy_options(options: &GuestfsCopyOptions) -> Result<()> {
if let GuestfsDiskFormat::Named(format) = &options.disk_format {
if format.trim().is_empty() {
return Err(anyhow!("disk image format must not be empty"));
}
}
if !options.disk_image.is_file() {
return Err(anyhow!(
"guest disk image '{}' does not exist or is not a regular file",
options.disk_image.display()
));
}
if options.copy_paths.is_empty() {
return Err(anyhow!(
"at least one absolute guest path must be supplied for libguestfs copy-out"
));
}
for copy_path in &options.copy_paths {
if copy_path.guest_path.is_empty() || !copy_path.guest_path.starts_with('/') {
return Err(anyhow!(
"guest path '{}' must be absolute for libguestfs copy-out",
copy_path.guest_path
));
}
if !is_safe_relative_local_parent(©_path.local_parent) {
return Err(anyhow!(
"local parent '{}' for guest path '{}' must be a relative path inside the copy destination",
copy_path.local_parent.display(),
copy_path.guest_path
));
}
}
Ok(())
}
fn is_safe_relative_local_parent(path: &Path) -> bool {
!path.is_absolute()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
}
fn copy_paths_with_libguestfs(options: &GuestfsCopyOptions, destination: &Path) -> Result<()> {
let disk_image = path_to_guestfs_arg(&options.disk_image, "guest disk image")?;
let guestfs = Handle::create().context("create libguestfs handle")?;
guestfs
.add_drive(
disk_image,
AddDriveOptArgs {
readonly: Some(true),
format: options.disk_format.as_guestfs_format(),
},
)
.with_context(|| {
format!(
"add {} image '{}' to libguestfs read-only",
options.disk_format,
options.disk_image.display()
)
})?;
guestfs.launch().with_context(|| {
format!(
"launch libguestfs appliance for '{}'",
options.disk_image.display()
)
})?;
let extraction_result = (|| -> Result<()> {
mount_guest_filesystems(&guestfs, &options.mount_mode, &options.copy_paths)?;
for copy_path in &options.copy_paths {
let should_copy = copy_path.required
|| optional_guest_path_is_copyable(&guestfs, ©_path.guest_path)?;
if !should_copy {
continue;
}
let local_parent = destination.join(©_path.local_parent);
fs::create_dir_all(&local_parent).with_context(|| {
format!(
"create local parent '{}' for guest path '{}'",
local_parent.display(),
copy_path.guest_path
)
})?;
let local_parent_arg = path_to_guestfs_arg(&local_parent, "guest copy destination")?;
guestfs
.copy_out(©_path.guest_path, local_parent_arg)
.with_context(|| {
format!(
"copy guest path '{}' to '{}'",
copy_path.guest_path,
local_parent.display()
)
})?;
}
Ok(())
})();
let umount_result = guestfs.umount_all();
let shutdown_result = guestfs.shutdown();
extraction_result?;
umount_result.context("unmount guest filesystems")?;
shutdown_result.context("shutdown libguestfs appliance")?;
Ok(())
}
fn path_to_guestfs_arg<'a>(path: &'a Path, description: &str) -> Result<&'a str> {
path.to_str().ok_or_else(|| {
anyhow!(
"{} '{}' is not valid UTF-8; libguestfs paths must be passed as strings",
description,
path.display()
)
})
}
fn optional_guest_path_is_copyable(guestfs: &Handle, guest_path: &str) -> Result<bool> {
if !guestfs
.exists(guest_path)
.with_context(|| format!("check whether guest path '{guest_path}' exists"))?
{
return Ok(false);
}
Ok(!guestfs
.is_symlink(guest_path)
.with_context(|| format!("check whether guest path '{guest_path}' is a symlink"))?)
}
fn mount_guest_filesystems(
guestfs: &Handle,
mount_mode: &GuestfsMountMode,
copy_paths: &[CopyPath],
) -> Result<()> {
match mount_mode {
GuestfsMountMode::Inspect => mount_inspected_filesystems(guestfs, copy_paths),
GuestfsMountMode::Manual(mounts) => mount_manual_filesystems(guestfs, mounts),
}
}
fn mount_inspected_filesystems(guestfs: &Handle, copy_paths: &[CopyPath]) -> Result<()> {
let roots = guestfs
.inspect_os()
.context("inspect guest operating systems")?;
let root = match roots.as_slice() {
[root] => root,
[] => {
return Err(anyhow!(
"libguestfs inspection did not find a guest operating system; pass --mount to specify the root filesystem manually"
));
}
_ => {
return Err(anyhow!(
"libguestfs inspection found multiple guest operating systems ({}); pass --mount to specify the root filesystem manually",
roots.join(", ")
));
}
};
let mountpoints = guestfs
.inspect_get_mountpoints(root)
.with_context(|| format!("get mountpoints for inspected guest root '{root}'"))?;
if mountpoints.is_empty() {
return Err(anyhow!(
"libguestfs inspection did not report any mountpoints for guest root '{root}'"
));
}
let mut mountpoints = Vec::from_iter(mountpoints);
mountpoints.sort_by(|(left_mountpoint, _), (right_mountpoint, _)| {
left_mountpoint
.len()
.cmp(&right_mountpoint.len())
.then_with(|| left_mountpoint.cmp(right_mountpoint))
});
for (mountpoint, mountable) in mountpoints {
if copy_paths
.iter()
.any(|copy_path| guest_path_uses_mountpoint(©_path.guest_path, &mountpoint))
{
mount_inspected_filesystem(guestfs, &mountable, &mountpoint)?;
}
}
Ok(())
}
fn guest_path_uses_mountpoint(guest_path: &str, mountpoint: &str) -> bool {
if mountpoint == "/" {
return true;
}
guest_path == mountpoint
|| guest_path
.strip_prefix(mountpoint.trim_end_matches('/'))
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn mount_inspected_filesystem(guestfs: &Handle, mountable: &str, mountpoint: &str) -> Result<()> {
match guestfs.mount_ro(mountable, mountpoint) {
Ok(()) => Ok(()),
Err(mount_ro_error) => guestfs
.mount_vfs("ro,ufstype=ufs2", "ufs", mountable, mountpoint)
.with_context(|| {
format!(
"mount inspected guest filesystem '{mountable}' at '{mountpoint}' read-only; plain mount_ro failed first: {mount_ro_error:#}"
)
}),
}
}
fn mount_manual_filesystems(guestfs: &Handle, mounts: &[String]) -> Result<()> {
if mounts.is_empty() {
return Err(anyhow!(
"manual guestfs mount mode requires at least one mount"
));
}
for mount in mounts {
let mount = parse_manual_mount(mount)?;
mount.apply(guestfs)?;
}
Ok(())
}
#[derive(Debug, Eq, PartialEq)]
struct ManualMount<'a> {
mountable: &'a str,
mountpoint: &'a str,
options: Option<&'a str>,
fstype: Option<&'a str>,
}
impl ManualMount<'_> {
fn apply(&self, guestfs: &Handle) -> Result<()> {
let options = readonly_mount_options(self.options);
let result = match self.fstype {
Some(fstype) => guestfs.mount_vfs(&options, fstype, self.mountable, self.mountpoint),
None if self.options.is_some() => {
guestfs.mount_options(&options, self.mountable, self.mountpoint)
}
None => guestfs.mount_ro(self.mountable, self.mountpoint),
};
result.with_context(|| {
format!(
"mount manual guest filesystem '{}' at '{}' read-only",
self.mountable, self.mountpoint
)
})
}
}
fn parse_manual_mount(spec: &str) -> Result<ManualMount<'_>> {
let mut parts = spec.splitn(4, ':');
let mountable = parts.next().unwrap_or_default();
if mountable.is_empty() {
return Err(anyhow!("manual guestfs mount '{spec}' is missing a device"));
}
let mountpoint = parts.next().filter(|part| !part.is_empty()).unwrap_or("/");
if !mountpoint.starts_with('/') {
return Err(anyhow!(
"manual guestfs mount '{spec}' has non-absolute mountpoint '{mountpoint}'"
));
}
let options = parts.next().filter(|part| !part.is_empty());
let fstype = parts.next().filter(|part| !part.is_empty());
Ok(ManualMount {
mountable,
mountpoint,
options,
fstype,
})
}
fn readonly_mount_options(options: Option<&str>) -> String {
match options {
Some(options) if options.split(',').any(|option| option == "ro") => options.to_owned(),
Some(options) => format!("{options},ro"),
None => "ro".to_owned(),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SysrootKind {
Linux,
#[cfg(feature = "bsd")]
Bsd,
}
impl fmt::Display for SysrootKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Linux => formatter.write_str("Linux"),
#[cfg(feature = "bsd")]
Self::Bsd => formatter.write_str("BSD"),
}
}
}
pub(crate) struct SysrootExtract {
pub kind: SysrootKind,
pub output_parent: PathBuf,
pub dir_name: String,
pub copy_options: GuestfsCopyOptions,
pub force: bool,
}
pub(crate) fn extract_sysroot(
spec: SysrootExtract,
normalize: impl FnOnce(&Path) -> Result<()>,
validate: impl Fn(&Path) -> Result<()>,
) -> Result<PathBuf> {
let output_path = spec.output_parent.join(&spec.dir_name);
if output_path.exists() {
if !spec.force {
validate(&output_path).with_context(|| {
format!(
"existing {} sysroot '{}' is incomplete; pass --force to rebuild it",
spec.kind,
output_path.display()
)
})?;
return Ok(output_path);
}
fs::remove_dir_all(&output_path).with_context(|| {
format!(
"remove existing {} sysroot '{}' before rebuilding",
spec.kind,
output_path.display()
)
})?;
}
if !spec.copy_options.disk_image.is_file() {
return Err(anyhow!(
"root disk image '{}' does not exist or is not a regular file",
spec.copy_options.disk_image.display()
));
}
fs::create_dir_all(&spec.output_parent).with_context(|| {
format!(
"create {} sysroot output parent '{}'",
spec.kind,
spec.output_parent.display()
)
})?;
let temp_dir = tempfile::Builder::new()
.prefix(&format!(".{}.", spec.dir_name))
.tempdir_in(&spec.output_parent)
.with_context(|| {
format!(
"create temporary {} sysroot under '{}'",
spec.kind,
spec.output_parent.display()
)
})?;
copy_paths_from_guest(spec.copy_options, temp_dir.path())?;
normalize(temp_dir.path())?;
validate(temp_dir.path())?;
if output_path.exists() {
return Err(anyhow!(
"{} sysroot '{}' appeared while extracting; retry with --force if it should be replaced",
spec.kind,
output_path.display()
));
}
let temp_path = temp_dir.keep();
fs::rename(&temp_path, &output_path).with_context(|| {
format!(
"install extracted {} sysroot '{}' into '{}'",
spec.kind,
temp_path.display(),
output_path.display()
)
})?;
Ok(output_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guestfs_disk_format_parses_explicit_formats_and_auto() {
assert_eq!(
"qcow2".parse::<GuestfsDiskFormat>().unwrap(),
GuestfsDiskFormat::Named("qcow2".to_owned())
);
assert_eq!(
"raw".parse::<GuestfsDiskFormat>().unwrap(),
GuestfsDiskFormat::Named("raw".to_owned())
);
assert_eq!(
"AUTO".parse::<GuestfsDiskFormat>().unwrap(),
GuestfsDiskFormat::Auto
);
assert!(" ".parse::<GuestfsDiskFormat>().is_err());
}
#[test]
fn guestfs_copy_options_validate_paths_and_destinations() -> Result<()> {
let dir = tempfile::tempdir()?;
let image = dir.path().join("root.qcow2");
fs::write(&image, b"qcow2 placeholder")?;
let relative_path = GuestfsCopyOptions::new(&image, ["etc/passwd"]);
assert!(validate_guestfs_copy_options(&relative_path).is_err());
let absolute_path = GuestfsCopyOptions::new(&image, ["/etc/passwd"]);
validate_guestfs_copy_options(&absolute_path)?;
let empty_format = GuestfsCopyOptions::new(&image, ["/etc/passwd"])
.with_disk_format(GuestfsDiskFormat::Named("".to_owned()));
assert!(validate_guestfs_copy_options(&empty_format).is_err());
let unsafe_parent = sysroot_copy_options(
&image,
&[CopySpec {
guest_path: "/etc/passwd",
local_parent: "../outside",
}],
&[],
);
assert!(validate_guestfs_copy_options(&unsafe_parent).is_err());
let safe_parent = sysroot_copy_options(
&image,
&[CopySpec {
guest_path: "/etc/passwd",
local_parent: "usr/share",
}],
&[],
);
validate_guestfs_copy_options(&safe_parent)?;
Ok(())
}
#[test]
fn manual_mount_spec_defaults_to_root_mountpoint() -> Result<()> {
assert_eq!(
parse_manual_mount("/dev/sda3")?,
ManualMount {
mountable: "/dev/sda3",
mountpoint: "/",
options: None,
fstype: None,
}
);
assert_eq!(
parse_manual_mount("/dev/sda3:/")?,
ManualMount {
mountable: "/dev/sda3",
mountpoint: "/",
options: None,
fstype: None,
}
);
Ok(())
}
#[test]
fn manual_mount_spec_accepts_options_and_fstype() -> Result<()> {
assert_eq!(
parse_manual_mount("/dev/sda3:/usr:noatime:ufs")?,
ManualMount {
mountable: "/dev/sda3",
mountpoint: "/usr",
options: Some("noatime"),
fstype: Some("ufs"),
}
);
Ok(())
}
#[test]
fn manual_mount_spec_rejects_empty_device() {
assert!(parse_manual_mount(":/").is_err());
}
#[test]
fn manual_mount_spec_rejects_relative_mountpoint() {
assert!(parse_manual_mount("/dev/sda3:usr").is_err());
}
#[test]
fn manual_mount_options_are_read_only() {
assert_eq!(readonly_mount_options(None), "ro");
assert_eq!(readonly_mount_options(Some("noatime")), "noatime,ro");
assert_eq!(readonly_mount_options(Some("ro,noatime")), "ro,noatime");
}
}