diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index cb9433d08e..46595bf144 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -14,6 +14,12 @@ binaries. The driver embeds libkrun, libkrunfw, the guest OCI unpacker, the portable guest sandbox, and the custom kernel runtime. Each sandbox boots from a cached immutable bootstrap ext4 root disk plus a per-sandbox writable overlay disk. When the requested sandbox image differs from the bootstrap image, the driver prepares a read-only image ext4 disk inside a bootstrap VM and mounts that unpacked rootfs as the sandbox lowerdir. +Guest init creates missing `/dev/fd` and standard-stream links after mounting +procfs and devtmpfs, before using Bash process substitution. Existing links to +the expected procfs paths, or entries that resolve to the same files, stay +intact. Conflicting entries and unrelated dangling links fail boot with a +diagnostic rather than being replaced. + ## How it fits together Caller driver config is disabled by default, including upload workflows diff --git a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh index 37950675b0..97a99e1078 100644 --- a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh +++ b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh @@ -31,6 +31,31 @@ mount_initial_fs() { mount -t tmpfs tmpfs /tmp 2>/dev/null || true mount -t tmpfs tmpfs /run 2>/dev/null || true mount -t devtmpfs devtmpfs /dev 2>/dev/null || true + setup_fd_links /dev +} + +setup_fd_links() { + local dev_dir="$1" + local name target + for name in fd stdin stdout stderr; do + case "$name" in + fd) target=/proc/self/fd ;; + stdin) target=/proc/self/fd/0 ;; + stdout) target=/proc/self/fd/1 ;; + stderr) target=/proc/self/fd/2 ;; + esac + if [[ -L "$dev_dir/$name" ]] && [[ "$(readlink "$dev_dir/$name")" == "$target" ]]; then + continue + fi + if [[ -e "$dev_dir/$name" || -L "$dev_dir/$name" ]]; then + if [[ ! "$dev_dir/$name" -ef "$target" ]]; then + printf 'guest init: %s must refer to %s\n' "$dev_dir/$name" "$target" >&2 + return 1 + fi + else + ln -s "$target" "$dev_dir/$name" + fi + done } bind_mount_into_newroot() { diff --git a/crates/openshell-driver-vm/src/rootfs.rs b/crates/openshell-driver-vm/src/rootfs.rs index a292e81994..67846c10ac 100644 --- a/crates/openshell-driver-vm/src/rootfs.rs +++ b/crates/openshell-driver-vm/src/rootfs.rs @@ -1357,6 +1357,93 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn guest_init_creates_fd_links_and_preserves_existing_aliases() { + use std::os::unix::fs::MetadataExt as _; + + let dev = tempfile::tempdir().expect("temporary guest dev"); + let run = || run_guest_setup_fd_links(dev.path()); + let output = run(); + assert!(output.status.success(), "{output:?}"); + let aliases = [ + ("fd", "/proc/self/fd"), + ("stdin", "/proc/self/fd/0"), + ("stdout", "/proc/self/fd/1"), + ("stderr", "/proc/self/fd/2"), + ]; + let mut inodes = Vec::new(); + for (name, target) in aliases { + let path = dev.path().join(name); + assert_eq!(fs::read_link(&path).expect("read alias"), Path::new(target)); + inodes.push(fs::symlink_metadata(path).expect("stat alias").ino()); + } + let output = run(); + assert!(output.status.success(), "{output:?}"); + for ((name, _), inode) in aliases.into_iter().zip(inodes) { + assert_eq!( + fs::symlink_metadata(dev.path().join(name)) + .expect("stat retained alias") + .ino(), + inode, + "existing {name} alias stays intact" + ); + } + #[cfg(target_os = "linux")] + { + let stdin = dev.path().join("stdin"); + fs::remove_file(&stdin).expect("replace fixture alias"); + std::os::unix::fs::symlink("fd/0", &stdin).expect("equivalent relative alias"); + let output = run(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + fs::read_link(stdin).expect("retained relative alias"), + Path::new("fd/0") + ); + } + } + + #[test] + fn guest_init_rejects_conflicting_fd_entries_without_replacing_them() { + let dev = tempfile::tempdir().expect("temporary guest dev"); + let fd = dev.path().join("fd"); + fs::write(&fd, b"keep this entry").expect("conflicting file"); + let output = run_guest_setup_fd_links(dev.path()); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("must refer to /proc/self/fd")); + assert_eq!(fs::read(&fd).expect("retained file"), b"keep this entry"); + + fs::remove_file(&fd).expect("remove fixture file"); + std::os::unix::fs::symlink("missing-target", &fd).expect("dangling link"); + let output = run_guest_setup_fd_links(dev.path()); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("must refer to /proc/self/fd")); + assert_eq!( + fs::read_link(fd).expect("retained link"), + Path::new("missing-target") + ); + } + + fn run_guest_setup_fd_links(dev: &Path) -> std::process::Output { + let script = include_str!("../scripts/openshell-vm-sandbox-init.sh"); + let start = script + .find("setup_fd_links() {\n") + .expect("fd setup function"); + let tail = &script[start..]; + let end = tail.find("\n}\n").expect("fd setup function end") + "\n}\n".len(); + Command::new("bash") + .args([ + "-c", + &format!( + "set -euo pipefail\n{}\nsetup_fd_links \"$1\"\n", + &tail[..end] + ), + "test", + ]) + .arg(dev) + .output() + .expect("run shipped fd setup") + } + /// Runs the shipped guest init's `configure_hosts` against `root`. /// /// The function is cut out of the real script so the test exercises the