diff --git a/judge/README.md b/judge/README.md index 9f3090e..4792c61 100644 --- a/judge/README.md +++ b/judge/README.md @@ -5,7 +5,7 @@ Cargo workspace 分为五个 crate: | Crate | 职责 | 当前能力 | | --- | --- | --- | | `kf-contracts` | 跨服务通知和执行契约 | 最小 v1 通知、共享 JSON 样例检查 | -| `kf-sandbox` | Linux 隔离、限制、计量与清理 | 规格/配置检查与 `probe` 只读观察;`run` 拒绝执行 | +| `kf-sandbox` | Linux 隔离、限制、计量与清理 | 规格/配置检查、内部 cgroup/namespace 初始化;`run` 拒绝执行 | | `kf-engine` | 语言适配、测试点编排、比较与确定性汇总 | 预留库边界,无模拟判定 | | `kf-worker` | 拉取、租约、工件准备与本地编排 | `--check` 入口,无消费循环 | | `kf-coordinator` | 节点登记、租约、状态与结果写入权 | HTTP 存活检查,无任务端点 | @@ -98,8 +98,8 @@ FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜 `launcher/entry.rs` 在可信二进制的独立进程中运行,不启动 Tokio、不安装自定义 `pre_exec` 闭包,也不在 Worker 的 fork 子进程里运行 Rust 初始化逻辑。入口实际检查 `/proc/self/task` -只有一个线程;未来每次权限操作或 fork 前须再次检查。当前入口以普通身份运行,没有实现 -特权 helper、namespace、降权、seccomp 或用户程序执行,初始化返回准备阶段的能力不可用。 +只有一个线程;未来每次权限操作或 fork 前须再次检查。此控制入口以普通身份运行,不进入 namespace、降权、seccomp 或用户执行阶段, +初始化返回准备阶段的能力不可用。独立的特权 namespace 初始化入口见下文。 控制端必须先在可信 state 根下创建独占、权限 0700 的每次启动目录和 Unix listener,再通过 清空环境的 `Command` 启动内部入口;socket 不放在用户工作区,控制通道与用户 stdio 分开。 @@ -112,9 +112,10 @@ FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜 非法 run_id 和未知命令。所有 I/O 共用一个绝对截止时间(入口 5 秒,通道最大 10 秒), 部分帧不能续期;EOF、解析、超时或写入错误关闭并锁定通道,不能重新同步后继续初始化。 -新增 `rustix` 1.1.5 仅用于沙箱的安全 syscall/FD 封装,启用 `net`、`process`,由锁文件固定。 +`rustix` 1.1.5 用于沙箱的 syscall/FD 封装,由锁文件固定。 选择它的 OwnedFd/AsFd 接口完成连接与凭据核对;目前无需 `nix` 或直接 `libc` 调用, -也未放宽 `unsafe` 限制。参见 [rustix](https://github.com/bytecodealliance/rustix)、 +namespace 初始化另有一个局部、附安全理由的 `unshare_unsafe` 调用,workspace 的 unsafe deny +仍生效;不使用 FILES 标志,调用前检查独立入口只有一个线程。参见 [rustix](https://github.com/bytecodealliance/rustix)、 [socket_peercred](https://docs.rs/rustix/1.1.5/rustix/net/sockopt/fn.socket_peercred.html) 和 [fork 后的安全约束](https://doc.rust-lang.org/std/os/unix/process/trait.CommandExt.html#tymethod.pre_exec)。 @@ -158,6 +159,45 @@ memory.max、交换空间 0、pids.max、cpu.max 和 memory.oom.group,限制 [pidfd_open](https://man7.org/linux/man-pages/man2/pidfd_open.2.html)、 [PR_SET_PDEATHSIG](https://man7.org/linux/man-pages/man2/PR_SET_PDEATHSIG.2const.html)。 +## 实际 namespace 初始化 + +`isolation/namespaces.rs` 集中 namespace 策略及 OS 操作,`launcher/namespaces.rs` +负责进程与控制编排。它的 `NamespacedTarget` 只推进固定的可信初始化,不接收用户命令。 +它沿用停止目标及 cgroup 纳入流程;确认限额、成员和冻结状态后,通过私有 socketpair +发送初始化命令,再仅在此阶段解除冻结和继续 bootstrap。通道仍使用 1 KiB 帧及绝对截止时间, +协议只含 initialize/cancel 和初始化结果,没有用户 argv、路径或 exec 命令。 + +socketpair 的 SO_PEERCRED 标识创建者,不能当作子进程 PID 认证;安全依据是可信 Command +独占继承的端点。bootstrap 在 unshare 前核对创建者。PID 1 的对端位于祖先 PID namespace, +内核会把该对端 PID 映射为 0,rustix UCred 的非零 Pid 类型不能表示它,因此 PID 1 只使用 +已经认证并独占继承的端点,不在该角色再次调用 socket_peercred。控制 socket 暂占内部入口的 +FD 0,stdout/stderr 为 null;这里没有用户 stdio,最终 FD 白名单和 stdio 收集仍待实现。 + +初始化要求原 user namespace 中的 root helper,建立 mount/PID/IPC/UTS/network/cgroup +namespace,并立即把挂载传播递归设为 private。P0 选定的身份模型保留原 user namespace; +单 UID 的非特权 user namespace 映射不受支持。独立任务 UID/GID 租约、降权和 capabilities +清除属于后续身份阶段,当前 helper 仍有初始化权限,不能执行用户代码。 + +NEWPID 仅影响后续子进程,所以 bootstrap 再次 fresh exec 可信入口作为 namespace PID 1。 +PID 1 设置父线程死亡 SIGKILL,并利用尚未替换的祖先 procfs 核对实际外部父 PID。 +监督器读回其父子关系、NSpid、六类 namespace inode 与保留的 user namespace,随后从祖先 +namespace 通过 pidfd 使 PID 1 停止,再冻结整个任务 cgroup。bootstrap 仍位于祖先 PID +namespace,负责等待这个 init;用户进程树的 PID-1 回收循环尚未实现。 + +cgroup namespace 在实际任务成员关系建立后创建,PID 1 核对自己的 cgroup 视图为 `0::/`。 +它只虚拟化 cgroup 路径;限额和终止继续由外部监督器持有的 cgroup FD 执行。完成此阶段后, +最小运行根、受限 proc/dev、身份及 seccomp 均未就绪;`refuse_unimplemented_root` 返回 +root_filesystem 阶段拒绝并清理,CLI run 继续拒绝。 + +`namespace_vm.rs` 与 `blocked_vm.rs` 使用同一个 VM 条件守卫。独立 VM 中的固定 C 探针验证 +UTS 主机名、SysV IPC 隔离、新 network namespace 仅含未启用的 lo、跨任务挂载隔离和 +PID namespace 的后续子进程。探针由验收运行者在 VM 中进入目标 namespace,没有开放生产 +用户执行入口。权限测试还覆盖 pids.max 导致初始化失败及冻结目标丢弃;VM 自身的 PID 1 +在整体终止路径回收孤儿 helper,完整服务进程树回收仍待实现,不能据此宣称已完成。 + +参见 [PID namespace](https://man7.org/linux/man-pages/man7/pid_namespaces.7.html)、 +[cgroup namespace](https://man7.org/linux/man-pages/man7/cgroup_namespaces.7.html)。 + ## 可信运行配置与启动前检查 ```bash diff --git a/judge/crates/sandbox/Cargo.toml b/judge/crates/sandbox/Cargo.toml index c8230ea..e319e47 100644 --- a/judge/crates/sandbox/Cargo.toml +++ b/judge/crates/sandbox/Cargo.toml @@ -10,7 +10,7 @@ kf-contracts.workspace = true clap.workspace = true serde.workspace = true serde_json.workspace = true -rustix = { version = "1.1.5", features = ["net", "process", "fs"] } +rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio"] } [lints] workspace = true diff --git a/judge/crates/sandbox/src/cgroup.rs b/judge/crates/sandbox/src/cgroup.rs index d7311c5..844ecfe 100644 --- a/judge/crates/sandbox/src/cgroup.rs +++ b/judge/crates/sandbox/src/cgroup.rs @@ -218,6 +218,46 @@ impl TaskCgroup { Ok(self.limits.clone()) } + /// Private fixed initialization only, never a public user-exec release. + pub(crate) fn begin_namespace_initialization( + &mut self, + target: &mut BlockedTarget, + ) -> io::Result<()> { + target.verify_stopped()?; + self.applied_limits()?; + self.wait_event("frozen", "1", Duration::from_secs(1))?; + self.verify_members(&[target.pid()])?; + write_verify(&self.fd, "cgroup.freeze", "0")?; + self.wait_event("frozen", "0", Duration::from_secs(1))?; + target.continue_initialization() + } + + pub(crate) fn finish_namespace_initialization(&mut self, members: &[u32]) -> io::Result<()> { + self.verify_members(members)?; + self.applied_limits()?; + write_verify(&self.fd, "cgroup.freeze", "1")?; + self.wait_event("frozen", "1", Duration::from_secs(1))?; + self.verify_members(members) + } + + fn verify_members(&self, expected: &[u32]) -> io::Result<()> { + let mut members = read(&self.fd, "cgroup.procs")? + .split_whitespace() + .map(|value| { + value + .parse::() + .map_err(|_| invalid("invalid cgroup member")) + }) + .collect::>>()?; + let mut expected = expected.to_vec(); + members.sort_unstable(); + expected.sort_unstable(); + if members != expected { + return Err(invalid("unexpected cgroup members")); + } + Ok(()) + } + /// PID comes from the unreaped direct Child; callers cannot enroll arbitrary PIDs. pub fn enroll(&mut self, target: &mut BlockedTarget) -> Result<(), CgroupError> { self.live()?; diff --git a/judge/crates/sandbox/src/isolation/mod.rs b/judge/crates/sandbox/src/isolation/mod.rs new file mode 100644 index 0000000..f961714 --- /dev/null +++ b/judge/crates/sandbox/src/isolation/mod.rs @@ -0,0 +1,3 @@ +//! Fixed Linux isolation operations; launcher owns process/control orchestration. + +pub mod namespaces; diff --git a/judge/crates/sandbox/src/isolation/namespaces.rs b/judge/crates/sandbox/src/isolation/namespaces.rs new file mode 100644 index 0000000..6fa3624 --- /dev/null +++ b/judge/crates/sandbox/src/isolation/namespaces.rs @@ -0,0 +1,84 @@ +//! Namespace policy and kernel operations, restricted to a fresh trusted helper. + +use std::io; +use std::os::unix::fs::MetadataExt; + +/// Kernel namespace inode identities, observed by the trusted supervisor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NamespaceIds { + pub mount: u64, + pub pid: u64, + pub ipc: u64, + pub uts: u64, + pub network: u64, + pub cgroup: u64, + pub user: u64, +} + +impl NamespaceIds { + pub fn observe(pid: u32) -> io::Result { + let inode = |name| std::fs::metadata(format!("/proc/{pid}/ns/{name}")).map(|m| m.ino()); + Ok(Self { + mount: inode("mnt")?, + pid: inode("pid")?, + ipc: inode("ipc")?, + uts: inode("uts")?, + network: inode("net")?, + cgroup: inode("cgroup")?, + user: inode("user")?, + }) + } + + pub(crate) fn verify_separate(&self, parent: &Self) -> io::Result<()> { + if self.mount == parent.mount + || self.pid == parent.pid + || self.ipc == parent.ipc + || self.uts == parent.uts + || self.network == parent.network + || self.cgroup == parent.cgroup + || self.user != parent.user + { + return Err(io::Error::other( + "namespace/privileged-helper identity mismatch", + )); + } + Ok(()) + } +} + +/// One narrowly scoped unsafe call: rustix requires unsafe for all unshare +/// flags because FILES can split FD tables. We never use FILES or NEWUSER, +/// and run only in a fresh, verified single-threaded trusted helper. +#[allow(unsafe_code)] +pub(crate) fn create() -> io::Result<()> { + crate::launcher::entry::ensure_single_threaded()?; + // P0 policy: privileged initialization in the original user namespace, + // followed (in a later stage) by distinct task UID/GID and capability + // removal. Single-UID NEWUSER mapping is deliberately unsupported. + if rustix::process::geteuid().as_raw() != 0 + || rustix::process::getuid().as_raw() != 0 + || std::fs::metadata("/proc/self/ns/user")?.ino() + != std::fs::metadata("/proc/1/ns/user")?.ino() + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "requires original-user-namespace privileged helper", + )); + } + let flags = rustix::thread::UnshareFlags::NEWNS + | rustix::thread::UnshareFlags::NEWPID + | rustix::thread::UnshareFlags::NEWIPC + | rustix::thread::UnshareFlags::NEWUTS + | rustix::thread::UnshareFlags::NEWNET + | rustix::thread::UnshareFlags::NEWCGROUP; + // SAFETY: verified single thread, no FILES flag, no foreign FD-table users. + unsafe { + rustix::thread::unshare_unsafe(flags)?; + } + rustix::mount::mount_change( + "/", + rustix::mount::MountPropagationFlags::PRIVATE | rustix::mount::MountPropagationFlags::REC, + )?; + rustix::system::sethostname(b"kf-sandbox")?; + Ok(()) +} diff --git a/judge/crates/sandbox/src/launcher/blocked.rs b/judge/crates/sandbox/src/launcher/blocked.rs index ff172f2..750b3aa 100644 --- a/judge/crates/sandbox/src/launcher/blocked.rs +++ b/judge/crates/sandbox/src/launcher/blocked.rs @@ -7,6 +7,7 @@ use crate::spec::ValidatedSpec; use rustix::fd::{AsFd, OwnedFd}; use rustix::process::{Pid, PidfdFlags, Signal, WaitId, WaitIdOptions}; use std::io; +use std::os::unix::net::UnixStream; use std::path::Path; use std::process::{Child, Command, ExitStatus, Stdio}; use std::time::{Duration, Instant}; @@ -50,6 +51,30 @@ pub struct BlockedTarget { impl BlockedTarget { /// `executable` is the operator's trusted sandbox binary, never a request argv. pub fn spawn(executable: &Path) -> io::Result { + Self::spawn_inner(executable, None) + } + + pub(crate) fn spawn_initializing( + executable: &Path, + ) -> io::Result<(Self, super::protocol::ControlChannel)> { + let (parent, child) = UnixStream::pair()?; + // socketpair credentials identify its creator, not the eventual child. + // Authentication here is exclusive endpoint inheritance through this + // trusted Command; no user process exists or receives these endpoints. + let channel = super::protocol::ControlChannel::new( + parent, + super::protocol::ExpectedPeer { + pid: std::process::id(), + uid: rustix::process::geteuid().as_raw(), + gid: rustix::process::getegid().as_raw(), + }, + Instant::now() + super::protocol::MAX_CONTROL_TIMEOUT, + )?; + let target = Self::spawn_inner(executable, Some(child))?; + Ok((target, channel)) + } + + fn spawn_inner(executable: &Path, initialization: Option) -> io::Result { // A ignored/caught SIGCHLD could reap/recycle a PID before pidfd_open. // Fresh helpers retain the default disposition; reject inherited changes. let status = std::fs::read_to_string("/proc/self/status")?; @@ -66,15 +91,22 @@ impl BlockedTarget { )); } } - let mut child = Command::new(executable) + let mut command = Command::new(executable); + command .arg("bootstrap") .arg("--parent-pid") .arg(std::process::id().to_string()) .env_clear() .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn()?; + .stderr(Stdio::null()); + if let Some(socket) = initialization { + let fd: OwnedFd = socket.into(); + command + .arg("--initialize-namespaces") + .stdin(Stdio::from(fd)); + } + let mut child = command.spawn()?; let Some(pid) = Pid::from_raw(child.id() as i32) else { let _ = child.kill(); let _ = child.wait(); @@ -124,6 +156,14 @@ impl BlockedTarget { self.child.id() } + pub(crate) fn continue_initialization(&mut self) -> io::Result<()> { + self.verify_stopped()?; + Ok(rustix::process::pidfd_send_signal( + &self.pidfd, + Signal::CONT, + )?) + } + pub fn poll_exit(&mut self) -> io::Result> { let status = self.child.try_wait()?; if status.is_some() { @@ -183,7 +223,11 @@ pub struct InitializationFailure { pub cleanup_failure: Option, } -fn failure(stage: ExecutionStage, code: ErrorCode, error: Option<&io::Error>) -> ExecutionError { +pub(crate) fn failure( + stage: ExecutionStage, + code: ErrorCode, + error: Option<&io::Error>, +) -> ExecutionError { ExecutionError { stage, code, @@ -196,8 +240,8 @@ fn failure(stage: ExecutionStage, code: ErrorCode, error: Option<&io::Error>) -> /// Owns the real stopped child and its frozen task cgroup. No lifecycle event, /// JSON field or public method can turn this into a user-code execution permit. pub struct PreparedTarget { - target: BlockedTarget, - cgroup: TaskCgroup, + pub(crate) target: BlockedTarget, + pub(crate) cgroup: TaskCgroup, } impl PreparedTarget { @@ -215,17 +259,44 @@ impl PreparedTarget { ), cleanup_failure: None, })?; + Self::enroll(config, spec, &mut target).map(|cgroup| Self { target, cgroup }) + } + + pub(crate) fn prepare_initializing( + config: &ValidatedRuntimeConfig, + spec: &ValidatedSpec, + executable: &Path, + ) -> Result<(Self, super::protocol::ControlChannel), InitializationFailure> { + let (mut target, channel) = + BlockedTarget::spawn_initializing(executable).map_err(|error| { + InitializationFailure { + failure: failure( + ExecutionStage::Preparation, + ErrorCode::OperationFailed, + Some(&error), + ), + cleanup_failure: None, + } + })?; + Self::enroll(config, spec, &mut target).map(|cgroup| (Self { target, cgroup }, channel)) + } + + fn enroll( + config: &ValidatedRuntimeConfig, + spec: &ValidatedSpec, + target: &mut BlockedTarget, + ) -> Result { let setup = (|| { let root = DelegatedCgroup::open(config)?; let mut group = root.create_task(spec)?; - if let Err(mut error) = group.enroll(&mut target) { + if let Err(mut error) = group.enroll(target) { error.cleanup_error = group.kill_and_remove(CLEANUP).err(); return Err(error); } Ok::<_, crate::cgroup::CgroupError>(group) })(); match setup { - Ok(cgroup) => Ok(Self { target, cgroup }), + Ok(cgroup) => Ok(cgroup), Err(error) => { let reap_error = target.terminate().err(); Err(InitializationFailure { @@ -258,14 +329,18 @@ impl PreparedTarget { /// Missing isolation is a real initialization failure, never readiness. /// Kill/reap happens before a refusal is returned to the caller. pub fn refuse_unimplemented_isolation(mut self) -> InitializationFailure { + self.refuse_at(ExecutionStage::Namespaces, None) + } + + pub(crate) fn refuse_at( + &mut self, + stage: ExecutionStage, + error: Option<&io::Error>, + ) -> InitializationFailure { let group_error = self.cgroup.kill_and_remove(CLEANUP).err(); let reap_error = self.target.terminate().err(); InitializationFailure { - failure: failure( - ExecutionStage::Namespaces, - ErrorCode::CapabilityUnavailable, - None, - ), + failure: failure(stage, ErrorCode::CapabilityUnavailable, error), cleanup_failure: group_error.as_ref().or(reap_error.as_ref()).map(|e| { failure( ExecutionStage::Cleanup, diff --git a/judge/crates/sandbox/src/launcher/mod.rs b/judge/crates/sandbox/src/launcher/mod.rs index f0d2caf..c72833c 100644 --- a/judge/crates/sandbox/src/launcher/mod.rs +++ b/judge/crates/sandbox/src/launcher/mod.rs @@ -2,7 +2,8 @@ //! //! Lifecycle events describe completed operations, not commands received from //! an untrusted request. The standalone entry authenticates its parent and uses -//! bounded IPC, but refuses initialization until OS isolation is implemented. +//! bounded IPC, and refuses user execution. The separate namespace bootstrap initializes +//! only fixed OS isolation operations and remains gated at the missing root stage. pub mod lifecycle; @@ -11,4 +12,6 @@ pub mod blocked; #[cfg(target_os = "linux")] pub mod entry; #[cfg(target_os = "linux")] +pub mod namespaces; +#[cfg(target_os = "linux")] pub mod protocol; diff --git a/judge/crates/sandbox/src/launcher/namespaces.rs b/judge/crates/sandbox/src/launcher/namespaces.rs new file mode 100644 index 0000000..4fc6bd9 --- /dev/null +++ b/judge/crates/sandbox/src/launcher/namespaces.rs @@ -0,0 +1,230 @@ +//! Fixed privileged initialization, with a fresh exec for namespace PID 1. +//! This stage never accepts request argv and never enables user execution. + +use super::blocked::{InitializationFailure, PreparedTarget}; +use super::protocol::{ + ControlChannel, ExpectedPeer, MAX_CONTROL_TIMEOUT, NamespaceCommand, NamespaceEvent, +}; +use crate::outcome::ExecutionStage; +use crate::runtime::ValidatedRuntimeConfig; +use crate::spec::ValidatedSpec; +use rustix::process::Signal; +use std::io; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +pub use crate::isolation::namespaces::NamespaceIds; + +fn denied(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message) +} + +fn inherited_channel(parent_pid: u32) -> io::Result { + let fd = rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdin(), 3)?; + ControlChannel::new( + UnixStream::from(fd), + ExpectedPeer { + pid: parent_pid, + uid: rustix::process::geteuid().as_raw(), + gid: rustix::process::getegid().as_raw(), + }, + Instant::now() + MAX_CONTROL_TIMEOUT, + ) +} + +/// Fixed namespace bootstrap. The inherited socket is authenticated before +/// unshare; changing PID namespaces changes SO_PEERCRED's PID translation. +pub fn bootstrap(expected_parent: u32) -> io::Result<()> { + super::entry::ensure_single_threaded()?; + rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?; + if expected_parent <= 1 + || rustix::process::getppid().map(|pid| pid.as_raw_pid() as u32) != Some(expected_parent) + { + return Err(denied("bootstrap parent mismatch")); + } + let mut channel = inherited_channel(expected_parent)?; + rustix::process::kill_process(rustix::process::getpid(), Signal::STOP)?; + match channel.receive::()? { + NamespaceCommand::Cancel {} => { + channel.send(&NamespaceEvent::Cancelled {})?; + return Ok(()); + } + NamespaceCommand::Initialize {} => {} + } + let setup = (|| { + crate::isolation::namespaces::create()?; + super::entry::ensure_single_threaded()?; + // NEWPID applies to children, so a second fresh exec becomes PID 1. + // No Rust initialization occurs in a custom fork/pre_exec callback. + let child = Command::new(std::env::current_exe()?) + .arg("namespace-init") + .arg("--parent-pid") + .arg(std::process::id().to_string()) + .env_clear() + .stdin(Stdio::from(channel.duplicate_stream()?)) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok::<_, io::Error>(child) + })(); + let mut child = match setup { + Ok(child) => child, + Err(error) => { + channel.send(&NamespaceEvent::Failed { + errno: error.raw_os_error().and_then(|n| u16::try_from(n).ok()), + })?; + return Err(error); + } + }; + // PID 1 is the sole control writer now. Release the bootstrap's copies so + // control EOF has its actual meaning. The bootstrap owns/reaps PID 1 only; + // user descendants and the final init reaping loop are a separate stage. + rustix::stdio::dup2_stdin(std::fs::File::open("/dev/null")?)?; + drop(channel); + let status = child.wait()?; + if !status.success() { + return Err(denied("namespace init refused execution")); + } + Ok(()) +} + +fn status_value(status: &str, name: &str) -> io::Result { + status + .lines() + .find_map(|line| line.strip_prefix(name)) + .and_then(|raw| raw.trim().parse().ok()) + .ok_or_else(|| io::Error::other("missing process identity")) +} + +/// This helper runs before a new proc mount: the inherited procfs still reports +/// outer PIDs, allowing the post-prctl parent check even when getppid() is zero +/// in the new PID namespace. Later root setup must replace this inherited proc. +pub fn namespace_init(expected_outer_parent: u32) -> io::Result<()> { + super::entry::ensure_single_threaded()?; + if rustix::process::getpid().as_raw_pid() != 1 || rustix::process::getppid().is_some() { + return Err(denied("not the namespace init")); + } + rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?; + let status = std::fs::read_to_string("/proc/self/status")?; + if expected_outer_parent <= 1 || status_value(&status, "PPid:")? != expected_outer_parent { + return Err(denied("namespace init parent mismatch")); + } + // The inherited proc still shows the outer PID, but the task's cgroup + // namespace must already be rooted at its own resource-limited group. + if std::fs::read_to_string("/proc/self/cgroup")?.trim() != "0::/" { + return Err(denied("cgroup namespace not rooted at the task")); + } + let fd = rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdin(), 3)?; + let mut channel = ControlChannel::inherited_from_authenticated_bootstrap( + UnixStream::from(fd), + Instant::now() + MAX_CONTROL_TIMEOUT, + )?; + channel.send(&NamespaceEvent::Initialized { + init_pid: status_value(&status, "Pid:")?, + })?; + // Only the supervisor in the ancestor PID namespace stops PID 1. Signals + // sent within the namespace have special PID-1 restrictions. While it + // verifies/stops us, this bounded receive can only cancel/refuse. + let _ = channel.receive::()?; + Err(denied("namespace init has no execution permit")) +} + +/// Actual namespaces only, not complete isolation or an execution permit. +/// Both helper processes remain in the same bounded, frozen task cgroup. +pub struct NamespacedTarget { + prepared: PreparedTarget, + init_pid: u32, + ids: NamespaceIds, +} + +impl NamespacedTarget { + pub fn prepare( + config: &ValidatedRuntimeConfig, + spec: &ValidatedSpec, + executable: &Path, + ) -> Result { + let (mut prepared, mut control) = + PreparedTarget::prepare_initializing(config, spec, executable)?; + let initialize = (|| { + let parent_ids = NamespaceIds::observe(std::process::id())?; + control.send(&NamespaceCommand::Initialize {})?; + prepared + .cgroup + .begin_namespace_initialization(&mut prepared.target)?; + let init_pid = match control.receive::()? { + NamespaceEvent::Initialized { init_pid } => init_pid, + NamespaceEvent::Failed { errno: Some(errno) } => { + return Err(io::Error::from_raw_os_error(i32::from(errno))); + } + _ => return Err(denied("namespace initialization failed")), + }; + let deadline = Instant::now() + Duration::from_secs(2); + let pid = rustix::process::Pid::from_raw(init_pid as i32) + .ok_or_else(|| denied("invalid namespace init PID"))?; + let pidfd = rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty())?; + // The trusted bootstrap creates exactly one child and waits for + // it. Verify its identity before signaling through the pidfd. + let status = std::fs::read_to_string(format!("/proc/{init_pid}/status"))?; + if status_value(&status, "PPid:")? != prepared.pid() { + return Err(denied("namespace init parent mismatch")); + } + rustix::process::pidfd_send_signal(&pidfd, Signal::STOP)?; + loop { + let status = std::fs::read_to_string(format!("/proc/{init_pid}/status"))?; + if status_value(&status, "PPid:")? != prepared.pid() + || !status + .lines() + .find_map(|line| line.strip_prefix("NSpid:")) + .is_some_and(|v| v.split_whitespace().last() == Some("1")) + { + return Err(denied("namespace init identity mismatch")); + } + if status.lines().any(|line| line == "State:\tT (stopped)") { + break; + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "namespace init stop deadline", + )); + } + std::thread::sleep(Duration::from_millis(2)); + } + let ids = NamespaceIds::observe(init_pid)?; + ids.verify_separate(&parent_ids)?; + prepared + .cgroup + .finish_namespace_initialization(&[prepared.pid(), init_pid])?; + Ok::<_, io::Error>((init_pid, ids)) + })(); + match initialize { + Ok((init_pid, ids)) => Ok(Self { + prepared, + init_pid, + ids, + }), + Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))), + } + } + + pub fn init_pid(&self) -> u32 { + self.init_pid + } + pub fn bootstrap_pid(&self) -> u32 { + self.prepared.pid() + } + pub fn namespace_ids(&self) -> &NamespaceIds { + &self.ids + } + pub fn cgroup(&self) -> &crate::cgroup::TaskCgroup { + self.prepared.cgroup() + } + + /// The next missing stage remains a hard refusal with actual group cleanup. + pub fn refuse_unimplemented_root(mut self) -> InitializationFailure { + self.prepared + .refuse_at(ExecutionStage::RootFilesystem, None) + } +} diff --git a/judge/crates/sandbox/src/launcher/protocol.rs b/judge/crates/sandbox/src/launcher/protocol.rs index f0bc675..939af0f 100644 --- a/judge/crates/sandbox/src/launcher/protocol.rs +++ b/judge/crates/sandbox/src/launcher/protocol.rs @@ -34,6 +34,23 @@ pub enum ChildMessage { Cancelled {}, } +/// A distinct fixed initialization protocol. There is no argv, host path, +/// readiness claim or exec/release command in this protocol. +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case", deny_unknown_fields)] +pub enum NamespaceCommand { + Initialize {}, + Cancel {}, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)] +pub enum NamespaceEvent { + Initialized { init_pid: u32 }, + Failed { errno: Option }, + Cancelled {}, +} + mod sealed { pub trait Sealed {} } @@ -44,6 +61,21 @@ pub trait WireMessage: sealed::Sealed + Serialize + DeserializeOwned { } impl sealed::Sealed for ParentMessage {} +impl sealed::Sealed for NamespaceCommand {} +impl WireMessage for NamespaceCommand { + fn valid(&self) -> bool { + true + } +} +impl sealed::Sealed for NamespaceEvent {} +impl WireMessage for NamespaceEvent { + fn valid(&self) -> bool { + match self { + Self::Initialized { init_pid } => *init_pid > 1, + _ => true, + } + } +} impl WireMessage for ParentMessage { fn valid(&self) -> bool { match self { @@ -88,13 +120,10 @@ pub struct ControlChannel { } impl ControlChannel { + pub(crate) fn duplicate_stream(&self) -> io::Result { + Ok(rustix::io::fcntl_dupfd_cloexec(&self.stream, 3)?) + } pub fn new(stream: UnixStream, peer: ExpectedPeer, deadline: Instant) -> io::Result { - let remaining = deadline - .checked_duration_since(Instant::now()) - .filter(|duration| !duration.is_zero() && *duration <= MAX_CONTROL_TIMEOUT) - .ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "invalid control deadline") - })?; let credentials = rustix::net::sockopt::socket_peercred(&stream)?; if credentials.pid.as_raw_pid() as u32 != peer.pid || credentials.uid.as_raw() != peer.uid @@ -105,6 +134,23 @@ impl ControlChannel { "unexpected control peer", )); } + Self::inherited_from_authenticated_bootstrap(stream, deadline) + } + + /// Only the fresh namespace-init role uses this. Its endpoint was checked + /// before unshare and exclusively inherited through the trusted Command. + /// Do not call socket_peercred in PID 1: a peer outside its PID namespace + /// has PID 0, which rustix UCred's nonzero Pid cannot represent. + pub(crate) fn inherited_from_authenticated_bootstrap( + stream: UnixStream, + deadline: Instant, + ) -> io::Result { + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero() && *duration <= MAX_CONTROL_TIMEOUT) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid control deadline") + })?; stream.set_nonblocking(false)?; stream.set_read_timeout(Some(remaining))?; stream.set_write_timeout(Some(remaining))?; @@ -290,6 +336,19 @@ mod tests { ); assert!(serde_json::from_str::(r#"{"command":"cancel"}"#).is_err()); assert!(serde_json::from_str::(r#"{"command":"release_exec"}"#).is_err()); + for raw in [ + r#"{"command":"initialize","argv":["/bin/sh"]}"#, + r#"{"command":"release_exec"}"#, + r#"{"command":"cancel","path":"/host"}"#, + ] { + assert!(serde_json::from_str::(raw).is_err()); + } + assert!( + serde_json::from_str::( + r#"{"event":"initialized","init_pid":2,"exec_ready":true}"# + ) + .is_err() + ); } #[test] diff --git a/judge/crates/sandbox/src/lib.rs b/judge/crates/sandbox/src/lib.rs index 33bb2dd..24c0b39 100644 --- a/judge/crates/sandbox/src/lib.rs +++ b/judge/crates/sandbox/src/lib.rs @@ -11,3 +11,6 @@ pub mod spec; #[cfg(target_os = "linux")] pub mod cgroup; + +#[cfg(target_os = "linux")] +mod isolation; diff --git a/judge/crates/sandbox/src/main.rs b/judge/crates/sandbox/src/main.rs index 1514965..f571f3f 100644 --- a/judge/crates/sandbox/src/main.rs +++ b/judge/crates/sandbox/src/main.rs @@ -41,6 +41,15 @@ enum Command { Bootstrap { #[arg(long)] parent_pid: u32, + #[arg(long)] + initialize_namespaces: bool, + }, + /// Internal fresh PID-1 helper; refuses arbitrary commands and user exec. + #[cfg(target_os = "linux")] + #[command(hide = true)] + NamespaceInit { + #[arg(long)] + parent_pid: u32, }, } @@ -77,8 +86,16 @@ fn main() -> ExitCode { } } #[cfg(target_os = "linux")] - Command::Bootstrap { parent_pid } => { - match kf_sandbox::launcher::blocked::bootstrap(parent_pid) { + Command::Bootstrap { + parent_pid, + initialize_namespaces, + } => { + let result = if initialize_namespaces { + kf_sandbox::launcher::namespaces::bootstrap(parent_pid) + } else { + kf_sandbox::launcher::blocked::bootstrap(parent_pid) + }; + match result { Ok(()) => ExitCode::from(2), Err(error) => spec_error( "bootstrap_refused", @@ -86,6 +103,16 @@ fn main() -> ExitCode { ), } } + #[cfg(target_os = "linux")] + Command::NamespaceInit { parent_pid } => { + match kf_sandbox::launcher::namespaces::namespace_init(parent_pid) { + Ok(()) => ExitCode::from(2), + Err(error) => spec_error( + "namespace_init_refused", + Some(serde_json::json!({"io_kind":format!("{:?}",error.kind())})), + ), + } + } } } diff --git a/judge/crates/sandbox/tests/blocked_vm.rs b/judge/crates/sandbox/tests/blocked_vm.rs index 7d1f293..bf99fb5 100644 --- a/judge/crates/sandbox/tests/blocked_vm.rs +++ b/judge/crates/sandbox/tests/blocked_vm.rs @@ -6,116 +6,13 @@ use kf_sandbox::cgroup::DelegatedCgroup; use kf_sandbox::launcher::blocked::{BlockedTarget, PreparedTarget}; use kf_sandbox::outcome::{ErrorCode, ExecutionStage}; -use kf_sandbox::runtime::{RuntimeConfig, ValidatedRuntimeConfig}; -use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::os::unix::process::ExitStatusExt; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::path::Path; use std::time::{Duration, Instant}; - -static ATTEMPT: AtomicU64 = AtomicU64::new(0); - -fn require_vm() { - assert_eq!( - std::env::var("KF_SANDBOX_INDEPENDENT_VM").as_deref(), - Ok("1") - ); - assert_eq!( - fs::read_to_string("/kf/INDEPENDENT_VM").unwrap().trim(), - "isolated-test-guest" - ); - assert!( - fs::read_to_string("/sys/class/dmi/id/sys_vendor") - .unwrap() - .contains("QEMU") - ); - for path in ["/mnt/hgfs", "/home/kronecker", "/root/.ssh"] { - assert!(!Path::new(path).exists()); - } - let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap(); - for forbidden in ["hgfs", "9p", "virtiofs"] { - assert!(!mounts.contains(forbidden)); - } - let net: Vec<_> = fs::read_dir("/sys/class/net") - .unwrap() - .map(|entry| entry.unwrap().file_name()) - .collect(); - assert_eq!(net, ["lo"]); -} - -struct Root { - path: PathBuf, - config: RuntimeConfig, -} - -impl Root { - fn new() -> Self { - require_vm(); - let path = PathBuf::from(format!( - "/sys/fs/cgroup/kf-vm-{}-{}", - std::process::id(), - ATTEMPT.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&path).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); - let mut config: RuntimeConfig = serde_json::from_str(include_str!( - "../../../../deploy/sandbox.runtime.example.json" - )) - .unwrap(); - config.delegated_cgroup_root = path.to_str().unwrap().into(); - config.aggregate.task_cpu_quota_us = 10_000; - Self { path, config } - } - fn validated(&self) -> ValidatedRuntimeConfig { - self.config.clone().validate().unwrap() - } -} - -impl Drop for Root { - fn drop(&mut self) { - if self.path.exists() { - let _ = fs::write(self.path.join("cgroup.kill"), "1"); - if let Ok(entries) = fs::read_dir(&self.path) { - for entry in entries.flatten() { - if entry.file_type().is_ok_and(|kind| kind.is_dir()) { - let _ = fs::remove_dir(entry.path()); - } - } - } - let _ = fs::remove_dir(&self.path); - } - } -} - -fn spec(id: &str, memory: u64, processes: u32) -> ValidatedSpec { - let mut spec: ExecutionSpec = - serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap(); - spec.run_id = id.into(); - spec.limits.memory_bytes = memory; - spec.limits.processes = processes; - spec.validate(&SpecPolicy::default()).unwrap() -} - -fn binary() -> PathBuf { - std::env::var_os("KF_SANDBOX_TEST_BIN") - .expect("VM sandbox path") - .into() -} -fn children() -> Vec { - let mut children = Vec::new(); - for task in fs::read_dir("/proc/self/task").unwrap() { - children.extend( - fs::read_to_string(task.unwrap().path().join("children")) - .unwrap() - .split_whitespace() - .map(|pid| pid.parse::().unwrap()), - ); - } - children.sort_unstable(); - children -} +mod support; +use support::{Root, binary, children, spec}; #[test] #[ignore = "requires independent VM with externally verified whole-VM caps"] diff --git a/judge/crates/sandbox/tests/fixtures/namespace_probe.c b/judge/crates/sandbox/tests/fixtures/namespace_probe.c new file mode 100644 index 0000000..1a7e518 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/namespace_probe.c @@ -0,0 +1,61 @@ +/* Fixed probe, compiled/executed only by the independently capped VM harness. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int enter(const char *pid, const char *name, int flag) { + char path[128]; + if (snprintf(path,sizeof path,"/proc/%s/ns/%s",pid,name)>=(int)sizeof path) return -1; + int fd=open(path,O_RDONLY|O_CLOEXEC); + if(fd<0) return -1; + int result=setns(fd,flag); close(fd); return result; +} +static int probe(const char *pid, int queue) { + char hostname[64]={0}; + if(enter(pid,"uts",CLONE_NEWUTS)||gethostname(hostname,sizeof hostname)||strcmp(hostname,"kf-sandbox"))return 10; + if(enter(pid,"ipc",CLONE_NEWIPC))return 11; + struct msqid_ds ds; + if(msgctl(queue,IPC_STAT,&ds)!=-1 || (errno!=EINVAL&&errno!=EIDRM))return 12; + if(enter(pid,"net",CLONE_NEWNET))return 13; + struct if_nameindex *names=if_nameindex(); + if(!names || strcmp(names[0].if_name,"lo") || names[1].if_index)return 14; + if_freenameindex(names); + int sock=socket(AF_INET,SOCK_DGRAM|SOCK_CLOEXEC,0); + struct ifreq iface={0};strcpy(iface.ifr_name,"lo"); + if(sock<0||ioctl(sock,SIOCGIFFLAGS,&iface)||iface.ifr_flags&IFF_UP)return 15; + close(sock); + if(enter(pid,"mnt",CLONE_NEWNS)||mount("tmpfs","/kf/ns-mount","tmpfs",MS_NOSUID|MS_NODEV|MS_NOEXEC,"size=1m,mode=0700"))return 16; + int file=open("/kf/ns-mount/sentinel",O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC,0600); + if(file<0||write(file,"isolated",8)!=8)return 17; + close(file); + if(enter(pid,"pid",CLONE_NEWPID))return 18; + pid_t child=fork(); + if(child==0)_exit(getpid()>1&&getppid()==0&&kill(1,0)==0?0:19); + int status; + if(child<0||waitpid(child,&status,0)!=child||!WIFEXITED(status)||WEXITSTATUS(status))return 20; + return 0; +} +int main(int argc,char **argv){ + alarm(4); + if(argc!=2)return 2; + for(char *p=argv[1];*p;p++)if(*p<'0'||*p>'9')return 2; + int queue=msgget(IPC_PRIVATE,IPC_CREAT|0600);if(queue<0)return 3; + pid_t child=fork();if(child==0)_exit(probe(argv[1],queue)); + int status=0;int ok=child>0&&waitpid(child,&status,0)==child&&WIFEXITED(status)&&WEXITSTATUS(status)==0; + if(msgctl(queue,IPC_RMID,NULL))return 4; + if(!ok){fprintf(stderr,"namespace probe status=%d\n",status);return 5;} + puts("KF_NAMESPACE_PROBE uts=1 ipc=1 net_lo_down=1 mount_private=1 pid_child=1");return 0; +} diff --git a/judge/crates/sandbox/tests/namespace_vm.rs b/judge/crates/sandbox/tests/namespace_vm.rs new file mode 100644 index 0000000..500893c --- /dev/null +++ b/judge/crates/sandbox/tests/namespace_vm.rs @@ -0,0 +1,148 @@ +#![cfg(target_os = "linux")] +//! Privileged acceptance runs exclusively under the independent VM harness. +mod support; + +use kf_sandbox::launcher::namespaces::{NamespaceIds, NamespacedTarget}; +use kf_sandbox::outcome::ExecutionStage; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; +use support::{Root, binary, children, spec}; + +fn gone(pid: u32) { + let deadline = Instant::now() + Duration::from_secs(2); + while Path::new(&format!("/proc/{pid}")).exists() { + assert!( + Instant::now() < deadline, + "VM init has not reaped orphaned helper" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +#[ignore = "requires independent VM with externally verified whole-VM caps"] +fn actual_namespaces_and_pid_one_are_distinct_and_remain_gated() { + let root = Root::new(); + let before = NamespaceIds::observe(std::process::id()).unwrap(); + let one = NamespacedTarget::prepare(&root.validated(), &spec("ns-one", 64 << 20, 8), &binary()) + .unwrap(); + let two = NamespacedTarget::prepare(&root.validated(), &spec("ns-two", 64 << 20, 8), &binary()) + .unwrap(); + let a = one.namespace_ids(); + let b = two.namespace_ids(); + assert_ne!(a.mount, b.mount); + assert_ne!(a.pid, b.pid); + assert_ne!(a.ipc, b.ipc); + assert_ne!(a.uts, b.uts); + assert_ne!(a.network, b.network); + assert_ne!(a.cgroup, b.cgroup); + assert_eq!(a.user, before.user); + assert_eq!(b.user, before.user); + let output = Command::new("/kf/bin/namespace-probe") + .arg(one.init_pid().to_string()) + .env_clear() + .output() + .unwrap(); + assert!( + output.status.success(), + "fixed probe: {:?} {:?}", + output.stdout, + output.stderr + ); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + "KF_NAMESPACE_PROBE uts=1 ipc=1 net_lo_down=1 mount_private=1 pid_child=1" + ); + assert!(!Path::new("/kf/ns-mount/sentinel").exists()); + assert!( + Path::new(&format!( + "/proc/{}/root/kf/ns-mount/sentinel", + one.init_pid() + )) + .exists() + ); + assert!( + !Path::new(&format!( + "/proc/{}/root/kf/ns-mount/sentinel", + two.init_pid() + )) + .exists() + ); + for target in [&one, &two] { + let path = root.path.join(target.cgroup().name()); + assert_eq!( + fs::read_to_string(path.join("cgroup.freeze")) + .unwrap() + .trim(), + "1" + ); + let status = fs::read_to_string(format!("/proc/{}/status", target.init_pid())).unwrap(); + assert!(status.contains("State:\tT (stopped)")); + assert!( + status + .lines() + .find_map(|l| l.strip_prefix("NSpid:")) + .unwrap() + .split_whitespace() + .last() + == Some("1") + ); + let mounts = fs::read_to_string(format!("/proc/{}/mountinfo", target.init_pid())).unwrap(); + assert!(!mounts.contains(" shared:") && !mounts.contains(" master:")); + let limits = target.cgroup().applied_limits().unwrap(); + assert_eq!(limits.memory_bytes, 64 << 20); + assert_eq!(limits.processes, 8); + } + assert_eq!(NamespaceIds::observe(std::process::id()).unwrap(), before); + let ids = (one.init_pid(), two.init_pid()); + for target in [one, two] { + let bootstrap = target.bootstrap_pid(); + let path = root.path.join(target.cgroup().name()); + let failure = target.refuse_unimplemented_root(); + assert_eq!(failure.failure.stage, ExecutionStage::RootFilesystem); + assert!(failure.cleanup_failure.is_none()); + assert!(!path.exists()); + gone(bootstrap); + } + gone(ids.0); + gone(ids.1); + println!( + "KF_NAMESPACE_PROOF init_pids={ids:?} six_namespaces_distinct=1 user_namespace_unchanged=1 frozen=1 root_refusal=1" + ); +} + +#[test] +#[ignore = "requires independent VM with externally verified whole-VM caps"] +fn pid_limit_initialization_failure_reports_namespace_stage_and_rolls_back() { + let root = Root::new(); + let before = children(); + let result = + NamespacedTarget::prepare(&root.validated(), &spec("ns-fail", 64 << 20, 1), &binary()); + let Err(failure) = result else { + panic!("PID 1 must not bypass pids.max"); + }; + assert_eq!(failure.failure.stage, ExecutionStage::Namespaces); + assert_eq!(failure.failure.errno, Some(11)); + assert!(failure.cleanup_failure.is_none()); + assert_eq!(children(), before); + assert!(!root.path.join("kf-ns-fail").exists()); + println!("KF_NAMESPACE_FAILURE_PROOF errno=11 stage=namespaces reaped=1 removed=1"); +} + +#[test] +#[ignore = "requires independent VM with externally verified whole-VM caps"] +fn dropping_frozen_namespace_target_kills_helpers_and_removes_task() { + let root = Root::new(); + let target = + NamespacedTarget::prepare(&root.validated(), &spec("ns-drop", 64 << 20, 8), &binary()) + .unwrap(); + let ids = (target.bootstrap_pid(), target.init_pid()); + let path = root.path.join(target.cgroup().name()); + drop(target); + gone(ids.0); + gone(ids.1); + assert!(!path.exists()); + println!("KF_NAMESPACE_DROP_PROOF helpers_gone=1 task_removed=1"); +} diff --git a/judge/crates/sandbox/tests/support/mod.rs b/judge/crates/sandbox/tests/support/mod.rs new file mode 100644 index 0000000..107cf63 --- /dev/null +++ b/judge/crates/sandbox/tests/support/mod.rs @@ -0,0 +1,108 @@ +use kf_sandbox::runtime::{RuntimeConfig, ValidatedRuntimeConfig}; +use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static ATTEMPT: AtomicU64 = AtomicU64::new(0); + +pub fn require_vm() { + assert_eq!( + std::env::var("KF_SANDBOX_INDEPENDENT_VM").as_deref(), + Ok("1") + ); + assert_eq!( + fs::read_to_string("/kf/INDEPENDENT_VM").unwrap().trim(), + "isolated-test-guest" + ); + assert!( + fs::read_to_string("/sys/class/dmi/id/sys_vendor") + .unwrap() + .contains("QEMU") + ); + for path in ["/mnt/hgfs", "/home/kronecker", "/root/.ssh"] { + assert!(!Path::new(path).exists()); + } + let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap(); + for forbidden in ["hgfs", "9p", "virtiofs"] { + assert!(!mounts.contains(forbidden)); + } + let net: Vec<_> = fs::read_dir("/sys/class/net") + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(net, ["lo"]); +} + +pub struct Root { + pub path: PathBuf, + config: RuntimeConfig, +} + +impl Root { + pub fn new() -> Self { + require_vm(); + let path = PathBuf::from(format!( + "/sys/fs/cgroup/kf-vm-{}-{}", + std::process::id(), + ATTEMPT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + let mut config: RuntimeConfig = serde_json::from_str(include_str!( + "../../../../../deploy/sandbox.runtime.example.json" + )) + .unwrap(); + config.delegated_cgroup_root = path.to_str().unwrap().into(); + config.aggregate.task_cpu_quota_us = 10_000; + Self { path, config } + } + pub fn validated(&self) -> ValidatedRuntimeConfig { + self.config.clone().validate().unwrap() + } +} + +impl Drop for Root { + fn drop(&mut self) { + if self.path.exists() { + let _ = fs::write(self.path.join("cgroup.kill"), "1"); + if let Ok(entries) = fs::read_dir(&self.path) { + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|kind| kind.is_dir()) { + let _ = fs::remove_dir(entry.path()); + } + } + } + let _ = fs::remove_dir(&self.path); + } + } +} + +pub fn spec(id: &str, memory: u64, processes: u32) -> ValidatedSpec { + let mut spec: ExecutionSpec = + serde_json::from_str(include_str!("../fixtures/spec.valid.json")).unwrap(); + spec.run_id = id.into(); + spec.limits.memory_bytes = memory; + spec.limits.processes = processes; + spec.validate(&SpecPolicy::default()).unwrap() +} + +pub fn binary() -> PathBuf { + std::env::var_os("KF_SANDBOX_TEST_BIN") + .expect("VM sandbox path") + .into() +} +pub fn children() -> Vec { + let mut children = Vec::new(); + for task in fs::read_dir("/proc/self/task").unwrap() { + children.extend( + fs::read_to_string(task.unwrap().path().join("children")) + .unwrap() + .split_whitespace() + .map(|pid| pid.parse::().unwrap()), + ); + } + children.sort_unstable(); + children +}