diff --git a/tests/run.sh b/tests/run.sh index cd4a58606..d08251680 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -24,8 +24,11 @@ fi GUEST_TARGET="${ARCH}-unknown-linux-musl" -# Run the unit tests first (this tests the testing framework itself not libkrun) -cargo test -p test_cases --features guest +# Run the unit tests first (this tests the testing framework itself not libkrun). +# Guest code may use Linux-only libc calls that won't compile with other toolchains. +if [ "$OS" = "Linux" ]; then + cargo test -p test_cases --features guest +fi # On macOS, we need to cross-compile for Linux musl if [ "$OS" = "Darwin" ]; then diff --git a/tests/test_cases/Cargo.toml b/tests/test_cases/Cargo.toml index 34d646797..6970c1c1e 100644 --- a/tests/test_cases/Cargo.toml +++ b/tests/test_cases/Cargo.toml @@ -12,6 +12,6 @@ name = "test_cases" [dependencies] krun-sys = { path = "../../krun-sys", optional = true } macros = { path = "../macros" } -nix = { version = "0.29.0", features = ["socket"] } +nix = { version = "0.29.0", features = ["fs", "socket"] } anyhow = "1.0.95" tempdir = "0.3.7" \ No newline at end of file diff --git a/tests/test_cases/src/common.rs b/tests/test_cases/src/common.rs index 6a3ee2483..17f277493 100644 --- a/tests/test_cases/src/common.rs +++ b/tests/test_cases/src/common.rs @@ -5,7 +5,7 @@ use std::ffi::CString; use std::fs; use std::fs::create_dir; use std::os::unix::ffi::OsStrExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::ptr::null; use crate::{krun_call, TestSetup}; @@ -20,6 +20,17 @@ fn copy_guest_agent(dir: &Path) -> anyhow::Result<()> { Ok(()) } +/// Creates the root filesystem directory and copies the guest agent into it. +/// Returns the path to the root directory. Use this when you need to configure the root +/// filesystem yourself (e.g. via `krun_add_virtiofs3` for read-only mounts) rather than +/// using the default `setup_fs_and_enter`. +pub fn setup_rootfs(test_setup: &TestSetup) -> anyhow::Result { + let root_dir = test_setup.tmp_dir.join("root"); + create_dir(&root_dir).context("Failed to create root directory")?; + copy_guest_agent(&root_dir)?; + Ok(root_dir) +} + /// Common part of most test. This setups an empty root filesystem, copies the guest agent there /// and runs the guest agent in the VM. /// Note that some tests might want to use a different root file system (perhaps a qcow image), @@ -28,11 +39,9 @@ fn copy_guest_agent(dir: &Path) -> anyhow::Result<()> { /// /// The returned object is used for deleting the temporary files. pub fn setup_fs_and_enter(ctx: u32, test_setup: TestSetup) -> anyhow::Result<()> { - let root_dir = test_setup.tmp_dir.join("root"); - create_dir(&root_dir).context("Failed to create root directory")?; + let root_dir = setup_rootfs(&test_setup)?; let path_str = CString::new(root_dir.as_os_str().as_bytes()).context("CString::new")?; - copy_guest_agent(&root_dir)?; unsafe { krun_call!(krun_set_root(ctx, path_str.as_ptr()))?; krun_call!(krun_set_workdir(ctx, c"/".as_ptr()))?; diff --git a/tests/test_cases/src/lib.rs b/tests/test_cases/src/lib.rs index f164ed87e..f1fb7da9b 100644 --- a/tests/test_cases/src/lib.rs +++ b/tests/test_cases/src/lib.rs @@ -13,6 +13,9 @@ use test_tsi_tcp_guest_listen::TestTsiTcpGuestListen; mod test_multiport_console; use test_multiport_console::TestMultiportConsole; +mod test_virtiofs_root_ro; +use test_virtiofs_root_ro::TestVirtiofsRootRo; + pub enum ShouldRun { Yes, No(&'static str), @@ -56,6 +59,7 @@ pub fn test_cases() -> Vec { Box::new(TestTsiTcpGuestListen::new()), ), TestCase::new("multiport-console", Box::new(TestMultiportConsole)), + TestCase::new("virtiofs-root-ro", Box::new(TestVirtiofsRootRo)), ] } diff --git a/tests/test_cases/src/test_virtiofs_root_ro.rs b/tests/test_cases/src/test_virtiofs_root_ro.rs new file mode 100644 index 000000000..1fff83ce2 --- /dev/null +++ b/tests/test_cases/src/test_virtiofs_root_ro.rs @@ -0,0 +1,217 @@ +// NOTE: This is a smoke test that asserts basic mutation operations fail on a read-only +// virtiofs root. It is not exhaustive.For a security sensitive test it would also be better +// to bypass the guest kernel and execute the virtiofs commands directly. + +use macros::{guest, host}; + +pub struct TestVirtiofsRootRo; + +const TEST_FILE: &str = "test-file"; +const TEST_CONTENT: &[u8] = b"original content"; +const EMPTY_DIR: &str = "empty-dir"; + +#[host] +mod host { + use super::*; + + use crate::common::setup_rootfs; + use crate::{krun_call, krun_call_u32}; + use crate::{Test, TestSetup}; + use krun_sys::*; + use std::ffi::CString; + use std::fs; + use std::os::unix::ffi::OsStrExt; + use std::ptr::null; + + impl Test for TestVirtiofsRootRo { + fn start_vm(self: Box, test_setup: TestSetup) -> anyhow::Result<()> { + let root_dir = setup_rootfs(&test_setup)?; + + // The guest init needs /dev, /proc, /sys as mount points. With a read-only + // root these must already exist in the host directory. + for dir in ["dev", "proc", "sys"] { + fs::create_dir(root_dir.join(dir))?; + } + fs::create_dir(root_dir.join(EMPTY_DIR))?; + fs::write(root_dir.join(TEST_FILE), TEST_CONTENT)?; + let root_path = CString::new(root_dir.as_os_str().as_bytes())?; + let test_case = CString::new(test_setup.test_case)?; + let argv = [test_case.as_ptr(), null()]; + let envp = [null()]; + + unsafe { + krun_call!(krun_set_log_level(KRUN_LOG_LEVEL_TRACE))?; + let ctx = krun_call_u32!(krun_create_ctx())?; + krun_call!(krun_set_vm_config(ctx, 1, 512))?; + + // Use "/dev/root" tag (KRUN_FS_ROOT_TAG) with read_only=true + krun_call!(krun_add_virtiofs3( + ctx, + c"/dev/root".as_ptr(), + root_path.as_ptr(), + 0, + true, + ))?; + + krun_call!(krun_set_workdir(ctx, c"/".as_ptr()))?; + krun_call!(krun_set_exec( + ctx, + c"/guest-agent".as_ptr(), + argv.as_ptr(), + envp.as_ptr(), + ))?; + krun_call!(krun_start_enter(ctx))?; + } + Ok(()) + } + } +} + +#[guest] +mod guest { + use super::*; + use crate::Test; + use nix::errno::Errno; + use nix::libc; + use nix::sys::stat::{mknod, stat, Mode, SFlag}; + use nix::unistd::{mkfifo, truncate}; + use std::fs; + use std::fs::Permissions; + use std::io::ErrorKind; + use std::os::unix::fs::{chown, symlink, PermissionsExt}; + use std::os::unix::net::UnixListener; + use std::path::Path; + + fn setxattr(path: &Path, name: &str, value: &[u8]) -> nix::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let c_path = CString::new(path.as_os_str().as_bytes()).unwrap(); + let c_name = CString::new(name).unwrap(); + let ret = unsafe { + libc::setxattr( + c_path.as_ptr(), + c_name.as_ptr(), + value.as_ptr() as *const libc::c_void, + value.len(), + 0, + ) + }; + Errno::result(ret).map(drop) + } + + /// Run `op` with `path`, assert it fails with EROFS, then verify `path` is unchanged. + fn assert_unchanged_after( + description: &str, + path: &Path, + snapshot: &nix::sys::stat::FileStat, + op: impl FnOnce(&Path) -> Result, + ) where + T: std::fmt::Debug, + E: Into, + { + match op(path) { + Err(e) => { + let err: std::io::Error = e.into(); + assert_eq!( + err.kind(), + ErrorKind::ReadOnlyFilesystem, + "Expected ReadOnlyFilesystem for {description}, got: {err}", + ); + } + Ok(val) => panic!("Expected ReadOnlyFilesystem for {description}, got: Ok({val:?})"), + } + + let after = stat(path).unwrap_or_else(|e| { + panic!("stat {} after {description}: {e}", path.display()); + }); + assert_eq!( + snapshot.st_size, after.st_size, + "{description}: size changed" + ); + assert_eq!( + snapshot.st_mode, after.st_mode, + "{description}: mode changed" + ); + assert_eq!(snapshot.st_uid, after.st_uid, "{description}: uid changed"); + assert_eq!(snapshot.st_gid, after.st_gid, "{description}: gid changed"); + assert_eq!( + snapshot.st_mtime, after.st_mtime, + "{description}: mtime changed" + ); + assert_eq!( + snapshot.st_mtime_nsec, after.st_mtime_nsec, + "{description}: mtime_nsec changed", + ); + assert_eq!( + snapshot.st_ctime, after.st_ctime, + "{description}: ctime changed" + ); + assert_eq!( + snapshot.st_ctime_nsec, after.st_ctime_nsec, + "{description}: ctime_nsec changed", + ); + if SFlag::from_bits_truncate(after.st_mode).contains(SFlag::S_IFREG) { + assert_eq!( + fs::read(path).unwrap_or_else(|_| panic!("read {}", path.display())), + TEST_CONTENT, + "{description}: content changed", + ); + } + } + + impl Test for TestVirtiofsRootRo { + fn in_guest(self: Box) { + let test_file = Path::new("/").join(TEST_FILE); + let empty_dir = Path::new("/").join(EMPTY_DIR); + let snap = stat(test_file.as_path()).expect("stat test-file"); + let dir_snap = stat(empty_dir.as_path()).expect("stat empty-dir"); + + // -- Operations that try to create new entries -- + assert_unchanged_after("write new file", &test_file, &snap, |_| { + fs::write("/new-file", b"hello") + }); + assert_unchanged_after("create dir", &test_file, &snap, |_| { + fs::create_dir("/new-dir") + }); + assert_unchanged_after("create symlink", &test_file, &snap, |_| { + symlink(TEST_FILE, "/new-symlink") + }); + assert_unchanged_after("create hard link", &test_file, &snap, |_| { + fs::hard_link(TEST_FILE, "/new-hardlink") + }); + assert_unchanged_after("create unix socket", &test_file, &snap, |_| { + UnixListener::bind("/new-socket").map(|_| ()) + }); + assert_unchanged_after("mkfifo", &test_file, &snap, |_| { + mkfifo("/new-fifo", Mode::S_IRUSR) + }); + assert_unchanged_after("mknod", &test_file, &snap, |_| { + mknod("/new-node", SFlag::S_IFREG, Mode::S_IRUSR, 0) + }); + + // -- Operations that try to mutate the existing test file -- + assert_unchanged_after("write existing file", &test_file, &snap, |p| { + fs::write(p, b"overwritten") + }); + assert_unchanged_after("truncate", &test_file, &snap, |p| truncate(p, 0)); + assert_unchanged_after("chmod", &test_file, &snap, |p| { + fs::set_permissions(p, Permissions::from_mode(0o777)) + }); + assert_unchanged_after("chown", &test_file, &snap, |p| { + chown(p, Some(12345), Some(12345)) + }); + assert_unchanged_after("rename", &test_file, &snap, |p| { + fs::rename(p, "/test-file-renamed") + }); + assert_unchanged_after("setxattr", &test_file, &snap, |p| { + setxattr(p, "user.test", b"value") + }); + + // -- Operations that try to remove existing entries -- + assert_unchanged_after("remove file", &test_file, &snap, |p| fs::remove_file(p)); + assert_unchanged_after("remove dir", &empty_dir, &dir_snap, |p| fs::remove_dir(p)); + + println!("OK"); + } + } +}