vmrunner-sysroot 0.0.5

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

use color_eyre::eyre::{Result, WrapErr, eyre};
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"];
const LINUX_REQUIRED_HEADER_FILES: &[&str] = &["usr/include/dirent.h", "usr/include/net/if.h"];
const LINUX_REQUIRED_HEADER_NAMES: &[&str] = &["sys/mount.h"];

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(|| eyre!("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}")),
    }
}

fn missing_linux_header_files(sysroot: &Path) -> Result<Vec<String>> {
    let mut missing = Vec::from_iter(
        LINUX_REQUIRED_HEADER_FILES
            .iter()
            .filter(|relative| !sysroot.join(relative).is_file())
            .map(|relative| (*relative).to_owned()),
    );
    for header_name in LINUX_REQUIRED_HEADER_NAMES {
        if !contains_linux_header_named(sysroot, header_name)? {
            missing.push(format!("usr/include/**/{header_name}"));
        }
    }
    Ok(missing)
}

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

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

fn contains_linux_header_named(sysroot: &Path, header_name: &str) -> Result<bool> {
    let include_dir = sysroot.join("usr/include");
    if !include_dir.is_dir() {
        return Ok(false);
    }
    if include_dir.join(header_name).is_file() {
        return Ok(true);
    }
    for entry in fs::read_dir(&include_dir).wrap_err_with(|| {
        format!(
            "read Linux sysroot include directory '{}'",
            include_dir.display()
        )
    })? {
        let entry = entry?;
        if entry.file_type()?.is_dir() && entry.path().join(header_name).is_file() {
            return Ok(true);
        }
    }
    Ok(false)
}

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)
            .wrap_err_with(|| 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/net"))?;
        fs::create_dir_all(dir.path().join("usr/include/sys"))?;
        fs::write(dir.path().join("usr/include/dirent.h"), b"dirent")?;
        fs::write(dir.path().join("usr/include/net/if.h"), b"if")?;
        fs::write(dir.path().join("usr/include/sys/mount.h"), b"mount")?;
        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(())
    }

    #[test]
    fn rejects_linux_sysroot_missing_init_krun_libc_headers() -> Result<()> {
        // Regression for cloud images whose extracted sysroot has runtime libc
        // but lacks the development headers needed by init.krun. The fix is to
        // update the qcow with those files before extraction, not to borrow them
        // from the host during sysroot validation.
        let dir = tempfile::tempdir()?;
        fs::create_dir_all(dir.path().join("usr/include"))?;
        fs::create_dir_all(dir.path().join("usr/lib64"))?;
        fs::write(dir.path().join("usr/lib64/libc.so.6"), b"runtime libc")?;

        let error = validate_linux_sysroot(dir.path()).unwrap_err().to_string();
        assert!(error.contains("missing required libc headers"));
        assert!(error.contains("usr/include/dirent.h"));
        assert!(error.contains("usr/include/net/if.h"));
        assert!(error.contains("usr/include/**/sys/mount.h"));
        Ok(())
    }

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

        validate_linux_sysroot(dir.path())
    }
}