feat(sandbox): initialize isolated namespaces through gated helpers

This commit is contained in:
2026-10-07 04:16:11 +08:00
parent 1a91c2e0f6
commit f22308080f
15 changed files with 912 additions and 134 deletions
+45 -5
View File
@@ -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
+1 -1
View File
@@ -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
+40
View File
@@ -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::<u32>()
.map_err(|_| invalid("invalid cgroup member"))
})
.collect::<io::Result<Vec<_>>>()?;
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()?;
@@ -0,0 +1,3 @@
//! Fixed Linux isolation operations; launcher owns process/control orchestration.
pub mod namespaces;
@@ -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<Self> {
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(())
}
+88 -13
View File
@@ -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> {
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<UnixStream>) -> io::Result<Self> {
// 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<Option<ExitStatus>> {
let status = self.child.try_wait()?;
if status.is_some() {
@@ -183,7 +223,11 @@ pub struct InitializationFailure {
pub cleanup_failure: Option<ExecutionError>,
}
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<TaskCgroup, InitializationFailure> {
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,
+4 -1
View File
@@ -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;
@@ -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<ControlChannel> {
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>()? {
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<u32> {
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::<NamespaceCommand>()?;
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<Self, InitializationFailure> {
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>()? {
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)
}
}
+65 -6
View File
@@ -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<u16> },
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<rustix::fd::OwnedFd> {
Ok(rustix::io::fcntl_dupfd_cloexec(&self.stream, 3)?)
}
pub fn new(stream: UnixStream, peer: ExpectedPeer, deadline: Instant) -> io::Result<Self> {
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<Self> {
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::<ChildMessage>(r#"{"command":"cancel"}"#).is_err());
assert!(serde_json::from_str::<ParentMessage>(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::<NamespaceCommand>(raw).is_err());
}
assert!(
serde_json::from_str::<NamespaceEvent>(
r#"{"event":"initialized","init_pid":2,"exec_ready":true}"#
)
.is_err()
);
}
#[test]
+3
View File
@@ -11,3 +11,6 @@ pub mod spec;
#[cfg(target_os = "linux")]
pub mod cgroup;
#[cfg(target_os = "linux")]
mod isolation;
+29 -2
View File
@@ -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())})),
),
}
}
}
}
+3 -106
View File
@@ -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<u32> {
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::<u32>().unwrap()),
);
}
children.sort_unstable();
children
}
mod support;
use support::{Root, binary, children, spec};
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
+61
View File
@@ -0,0 +1,61 @@
/* Fixed probe, compiled/executed only by the independently capped VM harness. */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <net/if.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/ipc.h>
#include <sys/mount.h>
#include <sys/msg.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
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;
}
+148
View File
@@ -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");
}
+108
View File
@@ -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<u32> {
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::<u32>().unwrap()),
);
}
children.sort_unstable();
children
}