vmrunner-sysroot 0.0.4

micro-vm runner sysroot extraction helpers
use std::{
    ffi::OsStr,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, anyhow};
use fs_err as fs;

use crate::{
    CopySpec, SysrootExtract, SysrootKind, SysrootOptions, extract_sysroot, sysroot_copy_options,
};

const LINUX_REQUIRED_COPY_PATHS: &[CopySpec] = &[
    CopySpec {
        guest_path: "/usr/include",
        local_parent: "usr",
    },
    CopySpec {
        guest_path: "/usr/lib",
        local_parent: "usr",
    },
];
const LINUX_OPTIONAL_COPY_PATHS: &[CopySpec] = &[
    CopySpec {
        guest_path: "/usr/lib64",
        local_parent: "usr",
    },
    CopySpec {
        guest_path: "/lib",
        local_parent: "",
    },
    CopySpec {
        guest_path: "/lib64",
        local_parent: "",
    },
];
const LINUX_LIBRARY_DIRS: &[&str] = &["lib", "lib64", "usr/lib", "usr/lib64"];
const LINUX_LIBC_NAMES: &[&str] = &["libc.so", "libc.so.6", "libc.a"];

pub fn extract_linux_sysroot(options: SysrootOptions) -> Result<PathBuf> {
    let SysrootOptions {
        root_image,
        guest_target,
        output_parent,
        disk_format,
        mount_mode,
        force,
    } = options;
    let dir_name = linux_sysroot_dir_name(&guest_target)?;
    let copy_options = sysroot_copy_options(
        root_image,
        LINUX_REQUIRED_COPY_PATHS,
        LINUX_OPTIONAL_COPY_PATHS,
    )
    .with_disk_format(disk_format)
    .with_mount_mode(mount_mode);

    extract_sysroot(
        SysrootExtract {
            kind: SysrootKind::Linux,
            output_parent,
            dir_name,
            copy_options,
            force,
        },
        |_| Ok(()),
        |path| validate_linux_sysroot(path),
    )
}

pub fn linux_sysroot_dir_name(guest_target: &str) -> Result<String> {
    let parts = Vec::from_iter(guest_target.split('-'));
    let arch = parts
        .first()
        .copied()
        .filter(|arch| !arch.is_empty())
        .ok_or_else(|| {
            anyhow!("Linux sysroot target '{guest_target}' is missing an architecture")
        })?;
    let linux_index = parts
        .iter()
        .position(|part| *part == "linux")
        .ok_or_else(|| unsupported_linux_target(guest_target))?;
    if parts.len() < 3 {
        return Err(unsupported_linux_target(guest_target));
    }
    match parts
        .get(linux_index + 1)
        .copied()
        .filter(|abi| !abi.is_empty())
    {
        Some(abi) => Ok(format!("linux-{arch}-{abi}")),
        None => Ok(format!("linux-{arch}")),
    }
}

pub fn validate_linux_sysroot(sysroot: impl AsRef<Path>) -> Result<()> {
    let sysroot = sysroot.as_ref();
    if !sysroot.join("usr/include").is_dir() {
        return Err(anyhow!(
            "Linux sysroot '{}' is missing required directory: usr/include",
            sysroot.display()
        ));
    }
    if contains_linux_libc(sysroot)? {
        return Ok(());
    }
    Err(anyhow!(
        "Linux sysroot '{}' is missing libc in one of: {}",
        sysroot.display(),
        LINUX_LIBRARY_DIRS.join(", ")
    ))
}

fn unsupported_linux_target(guest_target: &str) -> anyhow::Error {
    anyhow!(
        "unsupported Linux sysroot target '{guest_target}'; expected a Rust target triple containing 'linux', for example x86_64-unknown-linux-gnu"
    )
}

fn contains_linux_libc(sysroot: &Path) -> Result<bool> {
    for lib_dir in LINUX_LIBRARY_DIRS {
        if directory_tree_contains_file_named(&sysroot.join(lib_dir), LINUX_LIBC_NAMES, 3)? {
            return Ok(true);
        }
    }
    Ok(false)
}

fn directory_tree_contains_file_named(
    root: &Path,
    names: &[&str],
    max_depth: usize,
) -> Result<bool> {
    if !root.is_dir() {
        return Ok(false);
    }

    let mut stack = vec![(root.to_path_buf(), 0)];
    while let Some((dir, depth)) = stack.pop() {
        for entry in fs::read_dir(&dir)
            .with_context(|| format!("read Linux sysroot library directory '{}'", dir.display()))?
        {
            let entry = entry?;
            let file_name = entry.file_name();
            let path = entry.path();
            if names.iter().any(|name| file_name == OsStr::new(name)) && path.is_file() {
                return Ok(true);
            }
            if depth < max_depth && entry.file_type()?.is_dir() {
                stack.push((path, depth + 1));
            }
        }
    }
    Ok(false)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn linux_sysroot_dir_name_matches_rust_targets() {
        assert_eq!(
            linux_sysroot_dir_name("x86_64-unknown-linux-gnu").unwrap(),
            "linux-x86_64-gnu"
        );
        assert_eq!(
            linux_sysroot_dir_name("aarch64-unknown-linux-musl").unwrap(),
            "linux-aarch64-musl"
        );
        assert_eq!(
            linux_sysroot_dir_name("armv7-unknown-linux-gnueabihf").unwrap(),
            "linux-armv7-gnueabihf"
        );
        assert!(linux_sysroot_dir_name("x86_64-unknown-freebsd").is_err());
    }

    #[test]
    fn validates_linux_sysroot_headers_and_libc() -> Result<()> {
        let dir = tempfile::tempdir()?;
        fs::create_dir_all(dir.path().join("usr/include"))?;
        fs::create_dir_all(dir.path().join("usr/lib/x86_64-linux-gnu"))?;
        fs::write(
            dir.path().join("usr/lib/x86_64-linux-gnu/libc.so"),
            b"libc linker script",
        )?;

        validate_linux_sysroot(dir.path())?;

        fs::remove_file(dir.path().join("usr/lib/x86_64-linux-gnu/libc.so"))?;
        assert!(validate_linux_sysroot(dir.path()).is_err());
        Ok(())
    }
}