feat(sandbox): recover service-owned tasks after supervisor restart

This commit is contained in:
2026-10-08 13:46:02 +08:00
parent 22cc427da9
commit 32d94f3a66
14 changed files with 1335 additions and 26 deletions
+4
View File
@@ -10,6 +10,7 @@ use std::{fs, io, io::Read, os::unix::fs::PermissionsExt, path::Path};
pub(crate) mod isolation;
mod normal;
mod resources;
pub(crate) mod restart;
pub(crate) fn verify_guest(require_env: bool) -> io::Result<()> {
if (require_env && std::env::var("KF_SANDBOX_INDEPENDENT_VM").as_deref() != Ok("1"))
|| fs::read_to_string("/kf/INDEPENDENT_VM")?.trim() != "isolated-test-guest"
@@ -116,6 +117,9 @@ fn prefix(file: &crate::artifacts::SnapshotFile) -> io::Result<String> {
}
pub fn run_case(case: &str) -> io::Result<serde_json::Value> {
verify_guest(true)?;
if case.starts_with("restart-") {
return restart::run_case(case);
}
if case.starts_with("normal-") {
return normal::run_case(case);
}
@@ -0,0 +1,147 @@
//! Fixed real owner-death/restart cases in the independent capped VM.
use super::{Fixture, describe};
use crate::{
artifacts::ArtifactRoot,
launcher::{
capture::CaptureLease,
reap::OwnedChildren,
run::{self, MonitorMode},
seccomp::FilteredTarget,
},
runtime::{RuntimeConfig, ValidatedRuntimeConfig},
spec::{ExecutionSpec, SpecPolicy, ValidatedSpec},
};
use rustix::fs::{Mode, OFlags};
use std::{
fs,
io::{self, Read},
os::unix::fs::PermissionsExt,
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
pub(crate) fn ready(
config: &ValidatedRuntimeConfig,
spec: &ValidatedSpec,
stage: &str,
) -> io::Result<Child> {
super::verify_guest(true)?;
let mut child = Command::new(std::env::current_exe()?)
.args(["vm-case", "restart-residual"])
.env_clear()
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
// This PID belongs to our unreaped direct Child, not a request/frame.
let setup = (|| {
fs::write(
format!(
"{}/kf-{}/cgroup.procs",
config.config().delegated_cgroup_root,
spec.spec().run_id
),
child.id().to_string(),
)?;
let value = serde_json::json!({"stage":stage,"owner_pid":std::process::id(),"residual_pid":child.id(),"run_id":spec.spec().run_id});
fs::write("/kf/restart-ready.next", serde_json::to_vec(&value)?)?;
fs::rename("/kf/restart-ready.next", "/kf/restart-ready")?;
Ok::<_, io::Error>(())
})();
if let Err(e) = setup {
let _ = child.kill();
let _ = child.wait();
return Err(e);
}
Ok(child)
}
fn config() -> io::Result<ValidatedRuntimeConfig> {
let fd = rustix::fs::open(
"/kf/restart-runtime.json",
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let st = rustix::fs::fstat(&fd)?;
if st.st_uid != 0
|| st.st_gid != 0
|| st.st_mode & 0o7777 != 0o600
|| st.st_nlink != 1
|| rustix::fs::FileType::from_raw_mode(st.st_mode) != rustix::fs::FileType::RegularFile
{
return Err(io::Error::other("trusted restart fixture config ownership"));
}
let mut bytes = Vec::new();
std::fs::File::from(fd)
.take(crate::runtime::MAX_CONFIG_BYTES + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > crate::runtime::MAX_CONFIG_BYTES {
return Err(io::Error::other("restart config bound"));
}
let c: RuntimeConfig = serde_json::from_slice(&bytes)?;
if !c
.delegated_cgroup_root
.starts_with("/sys/fs/cgroup/kf-acceptance-")
{
return Err(io::Error::other("fixed acceptance cgroup only"));
}
c.validate().map_err(io::Error::other)
}
pub(super) fn run_case(case: &str) -> io::Result<serde_json::Value> {
if case == "restart-recover" {
return serde_json::to_value(crate::cleanup::recover(&config()?)?)
.map_err(io::Error::other);
}
if case == "restart-residual" {
// Real partial-preparation residual: no parent-death guarantee. It is
// bounded by the owning task and the independently capped VM, and its
// own 30s deadline prevents an unbounded acceptance helper.
std::thread::sleep(Duration::from_secs(30));
return Ok(serde_json::json!({"residual_deadline":true}));
}
if !matches!(case, "restart-prepared" | "restart-live") {
return Err(io::Error::other("unknown fixed restart case"));
}
let fixture = Fixture::new("/kf/bin/isolation-probe")?;
let config = fixture.config()?;
let root: ArtifactRoot = fixture.root()?;
let mut request: ExecutionSpec =
serde_json::from_str(include_str!("../../tests/fixtures/spec.valid.json"))?;
request.run_id = case.into();
request.argv.push("peer".into());
request.stdin_artifact = None;
request.limits.cpu_time_us = 2_000_000;
request.limits.wall_time_us = 30_000_000;
let spec = request
.validate(&SpecPolicy::default())
.map_err(io::Error::other)?;
if case == "restart-live" {
let completed = run::execute(
&config,
&spec,
&std::env::current_exe()?,
&root,
MonitorMode::RestartReady,
)
.map_err(|e| io::Error::other(format!("restart live {:?}", e.failure)))?;
return describe(case, &spec, &completed, false);
}
let mut owned = OwnedChildren::begin()?;
crate::launcher::capture::private_supervisor()?;
let mut target = FilteredTarget::prepare(&config, &spec, &std::env::current_exe()?, &root)
.map_err(|e| io::Error::other(format!("restart prepared {:?}", e.failure)))?;
let (mut lease, dir) = CaptureLease::prepare(&config, &spec)?;
let (pump, stdio) = crate::io::PipePump::prepare(target.take_stdin(), &spec, dir)?;
target.root_mut().target.prepare_execution(&spec, stdio)?;
let _child = ready(&config, &spec, "prepared")?;
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
drop(pump);
lease.cleanup()?;
let result = target.refuse_at(crate::outcome::ExecutionStage::Exec, None);
owned.finish()?;
fs::remove_file("/kf/restart-ready")?;
fs::set_permissions(&fixture.publications, fs::Permissions::from_mode(0o700))?;
Ok(serde_json::json!({"unexpected_probe_deadline":true,"cleanup_error":result.cleanup_failure}))
}
+10 -2
View File
@@ -55,6 +55,7 @@ pub struct DelegatedCgroup {
policy: SpecPolicy,
quota: u64,
period: u64,
recovery_config: ValidatedRuntimeConfig,
}
impl DelegatedCgroup {
@@ -117,6 +118,7 @@ impl DelegatedCgroup {
policy: config.spec_policy(),
quota: budget.task_cpu_quota_us,
period: budget.cpu_period_us,
recovery_config: config.clone(),
})
}
@@ -192,6 +194,12 @@ impl DelegatedCgroup {
// bootstrap. Charge all initialization and descendant CPU from
// enrollment; never estimate/subtract setup or reset peak later.
task.baseline = Some(task.accounting_snapshot()?);
crate::cleanup::register(
&self.recovery_config,
spec,
crate::cleanup::Resource::Cgroup,
&task.fd,
)?;
Ok::<_, io::Error>(())
})();
if let Err(source) = setup {
@@ -442,7 +450,7 @@ fn open_directory(path: &Path) -> io::Result<OwnedFd> {
Ok(fd)
}
fn read(fd: &OwnedFd, name: &str) -> io::Result<String> {
pub(crate) fn read(fd: &OwnedFd, name: &str) -> io::Result<String> {
let file = rustix::fs::openat(
fd,
name,
@@ -459,7 +467,7 @@ fn read(fd: &OwnedFd, name: &str) -> io::Result<String> {
Ok(text)
}
fn write(fd: &OwnedFd, name: &str, value: &str) -> io::Result<()> {
pub(crate) fn write(fd: &OwnedFd, name: &str, value: &str) -> io::Result<()> {
let file = rustix::fs::openat(
fd,
name,
+714
View File
@@ -0,0 +1,714 @@
//! Crash recovery of durable, root-owned task journals, never name-only adoption.
//! Only fixed descendants of operator-validated roots are eligible. Unknown,
//! replaced or incomplete resources remain reserved and produce a separate error.
use crate::{
outcome::{ErrorCode, ExecutionError, ExecutionStage},
runtime::ValidatedRuntimeConfig,
spec::ValidatedSpec,
};
use rustix::{
fd::{AsRawFd, OwnedFd},
fs::{AtFlags, FlockOperation, Mode, OFlags},
};
use serde::{Deserialize, Serialize};
use std::{
fs::File,
io::{self, Read, Write},
path::Path,
time::{Duration, Instant},
};
const SERVICE: &str = "kf-sandbox-task-v2";
const MAX_RECORD: u64 = 8192;
const DIR: OFlags = OFlags::RDONLY
.union(OFlags::DIRECTORY)
.union(OFlags::NOFOLLOW)
.union(OFlags::CLOEXEC);
fn denied(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, message)
}
fn private(fd: &OwnedFd, mode: u32) -> io::Result<()> {
let st = rustix::fs::fstat(fd)?;
if st.st_uid != 0 || st.st_gid != 0 || st.st_mode & 0o7777 != mode {
return Err(denied("unsafe recovery ownership/mode"));
}
Ok(())
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Inode {
dev: u64,
ino: u64,
}
impl Inode {
fn of(fd: &OwnedFd) -> io::Result<Self> {
let s = rustix::fs::fstat(fd)?;
Ok(Self {
dev: s.st_dev,
ino: s.st_ino,
})
}
fn check(&self, fd: &OwnedFd) -> io::Result<()> {
if *self != Self::of(fd)? {
return Err(denied("recorded resource inode changed"));
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Owner {
boot_id: String,
pid: u32,
start_ticks: u64,
}
fn start_ticks(text: &str) -> io::Result<u64> {
// comm may contain spaces and ')'; fields after its final ')' start at 3.
text.rsplit_once(") ")
.and_then(|(_, tail)| tail.split_whitespace().nth(19))
.and_then(|n| n.parse().ok())
.filter(|n| *n > 0)
.ok_or_else(|| denied("invalid owner process start time"))
}
fn boot_id() -> io::Result<String> {
let id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?
.trim()
.to_owned();
if id.len() != 36 || !id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') {
return Err(denied("invalid boot identity"));
}
Ok(id)
}
impl Owner {
fn current() -> io::Result<Self> {
Ok(Self {
boot_id: boot_id()?,
pid: std::process::id(),
start_ticks: start_ticks(&std::fs::read_to_string("/proc/self/stat")?)?,
})
}
fn alive(&self) -> io::Result<bool> {
// A different kernel boot invalidates cgroup inode identity. Fail closed,
// rather than treating a persistent record as authority over new groups.
if self.boot_id != boot_id()? {
return Err(denied("recovery record belongs to another kernel boot"));
}
match std::fs::read_to_string(format!("/proc/{}/stat", self.pid)) {
Ok(s) => Ok(start_ticks(&s)? == self.start_ticks),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e),
}
}
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Anchors {
state: Inode,
tasks: Inode,
captures: Inode,
cgroup: Inode,
}
struct Roots {
state: OwnedFd,
tasks: OwnedFd,
captures: OwnedFd,
cgroup: OwnedFd,
}
impl Roots {
fn open(config: &ValidatedRuntimeConfig) -> io::Result<Self> {
let c = config.config();
let open = crate::isolation::root::open_directory;
let state = open(Path::new(&c.state_root))?;
let tasks = open(Path::new(&c.tasks_root))?;
let captures = rustix::fs::openat(&state, "captures", DIR, Mode::empty())?;
private(&captures, 0o700)?;
let cgroup = open(Path::new(&c.delegated_cgroup_root))?;
if rustix::fs::fstatfs(&cgroup)?.f_type as u64 != 0x6367_7270 {
return Err(denied("recovery anchor is not cgroup v2"));
}
Ok(Self {
state,
tasks,
captures,
cgroup,
})
}
fn anchors(&self) -> io::Result<Anchors> {
Ok(Anchors {
state: Inode::of(&self.state)?,
tasks: Inode::of(&self.tasks)?,
captures: Inode::of(&self.captures)?,
cgroup: Inode::of(&self.cgroup)?,
})
}
}
#[derive(Clone, Copy)]
pub(crate) enum Resource {
Task,
Cgroup,
#[cfg(feature = "vm-acceptance")]
Capture,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Resources {
task: Option<Inode>,
cgroup: Option<Inode>,
capture: Option<Inode>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Record {
service: String,
version: u32,
run_id: String,
uid: u32,
gid: u32,
owner: Owner,
anchors: Anchors,
resources: Resources,
}
fn read_record(slot: &OwnedFd) -> io::Result<Record> {
let fd = rustix::fs::openat(
slot,
"owner.json",
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
private(&fd, 0o600)?;
let s = rustix::fs::fstat(&fd)?;
if rustix::fs::FileType::from_raw_mode(s.st_mode) != rustix::fs::FileType::RegularFile
|| s.st_nlink != 1
|| s.st_size < 0
|| s.st_size as u64 > MAX_RECORD
{
return Err(denied("invalid journal file type/link/size"));
}
let mut bytes = Vec::new();
// Inspect a path-only capability before reading: a foreign FIFO/device
// named owner.json must never block or perform device I/O at startup.
let reader = rustix::fs::open(
format!("/proc/self/fd/{}", fd.as_raw_fd()),
OFlags::RDONLY | OFlags::CLOEXEC,
Mode::empty(),
)?;
File::from(reader)
.take(MAX_RECORD + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_RECORD {
return Err(denied("journal read bound"));
}
let record: Record =
serde_json::from_slice(&bytes).map_err(|_| denied("unrecognized recovery record"))?;
crate::spec::identifier(&record.run_id, "run_id")
.map_err(|_| denied("invalid recovery task identifier"))?;
if record.service != SERVICE
|| record.version != 2
|| record.owner.pid <= 1
|| record.owner.start_ticks == 0
{
return Err(denied("unrecognized recovery owner"));
}
Ok(record)
}
fn store(slot: &OwnedFd, record: &Record) -> io::Result<()> {
let mut bytes = serde_json::to_vec(record)?;
bytes.push(b'\n');
if bytes.len() as u64 > MAX_RECORD {
return Err(denied("journal write bound"));
}
let fd = rustix::fs::openat(
slot,
".owner.next",
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
)?;
let mut file = File::from(fd);
file.write_all(&bytes)?;
file.sync_all()?;
rustix::fs::renameat(slot, ".owner.next", slot, "owner.json")?;
rustix::fs::fsync(slot)?;
Ok(())
}
pub(crate) fn initialize_slot(
config: &ValidatedRuntimeConfig,
spec: &ValidatedSpec,
slot: &OwnedFd,
uid: u32,
gid: u32,
) -> io::Result<()> {
rustix::fs::flock(slot, FlockOperation::NonBlockingLockExclusive)?;
private(slot, 0o700)?;
let state = crate::isolation::root::open_directory(Path::new(&config.config().state_root))?;
match rustix::fs::mkdirat(&state, "captures", Mode::from_raw_mode(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
Err(e) => return Err(e.into()),
}
let record = Record {
service: SERVICE.into(),
version: 2,
run_id: spec.spec().run_id.clone(),
uid,
gid,
owner: Owner::current()?,
anchors: Roots::open(config)?.anchors()?,
resources: Resources::default(),
};
store(slot, &record)
}
/// Primitive-only tests have no identity lease. A full task records each inode
/// before that resource can enroll a user process or mount a capture filesystem.
pub(crate) fn register(
config: &ValidatedRuntimeConfig,
spec: &ValidatedSpec,
resource: Resource,
fd: &OwnedFd,
) -> io::Result<()> {
let state = crate::isolation::root::open_directory(Path::new(&config.config().state_root))?;
let parent = match rustix::fs::openat(&state, "identities", DIR, Mode::empty()) {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) => return Ok(()),
Err(e) => return Err(e.into()),
};
private(&parent, 0o700)?;
let current = Owner::current()?;
for index in 0..config.config().max_concurrent_runs {
let (uid, gid) = config
.task_identity(index)
.ok_or_else(|| denied("identity slot"))?;
let slot = match rustix::fs::openat(&parent, format!("uid-{uid}"), DIR, Mode::empty()) {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) => continue,
Err(e) => return Err(e.into()),
};
private(&slot, 0o700)?;
let Ok(mut record) = read_record(&slot) else {
continue;
};
if record.owner != current || record.run_id != spec.spec().run_id {
continue;
}
if record.uid != uid
|| record.gid != gid
|| record.anchors != Roots::open(config)?.anchors()?
{
return Err(denied("journal binding mismatch"));
}
let value = match resource {
Resource::Task => &mut record.resources.task,
Resource::Cgroup => &mut record.resources.cgroup,
#[cfg(feature = "vm-acceptance")]
Resource::Capture => &mut record.resources.capture,
};
if value.is_some() {
return Err(denied("resource already registered"));
}
*value = Some(Inode::of(fd)?);
return store(&slot, &record);
}
Ok(())
}
pub(crate) fn verify_owned(
config: &ValidatedRuntimeConfig,
spec: &ValidatedSpec,
capture: bool,
) -> io::Result<()> {
let roots = Roots::open(config)?;
let parent = rustix::fs::openat(&roots.state, "identities", DIR, Mode::empty())?;
private(&parent, 0o700)?;
let current = Owner::current()?;
for index in 0..config.config().max_concurrent_runs {
let (uid, gid) = config
.task_identity(index)
.ok_or_else(|| denied("identity slot"))?;
let slot = match rustix::fs::openat(&parent, format!("uid-{uid}"), DIR, Mode::empty()) {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) => continue,
Err(e) => return Err(e.into()),
};
private(&slot, 0o700)?;
let Ok(record) = read_record(&slot) else {
continue;
};
if record.owner == current
&& record.run_id == spec.spec().run_id
&& record.uid == uid
&& record.gid == gid
&& record.anchors == roots.anchors()?
&& record.resources.task.is_some()
&& record.resources.cgroup.is_some()
&& (!capture || record.resources.capture.is_some())
{
return Ok(());
}
}
Err(denied("task has no complete durable recovery binding"))
}
fn names(fd: &OwnedFd) -> io::Result<Vec<String>> {
let entries: Vec<_> = std::fs::read_dir(format!("/proc/self/fd/{}", fd.as_raw_fd()))?
.take(257)
.map(|e| {
e?.file_name()
.into_string()
.map_err(|_| denied("non-UTF8 recovery entry"))
})
.collect::<io::Result<_>>()?;
if entries.len() > 256 {
return Err(denied("recovery directory bound"));
}
Ok(entries)
}
pub(crate) fn verify_release(slot: &OwnedFd) -> io::Result<()> {
if names(slot)?
.iter()
.any(|n| n != "owner.json" && n != ".owner.next")
{
return Err(denied("unknown identity directory content"));
}
for name in names(slot)? {
let fd = rustix::fs::openat(
slot,
name,
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
private(&fd, 0o600)?;
let s = rustix::fs::fstat(&fd)?;
if rustix::fs::FileType::from_raw_mode(s.st_mode) != rustix::fs::FileType::RegularFile
|| s.st_nlink != 1
|| s.st_size < 0
|| s.st_size as u64 > MAX_RECORD
{
return Err(denied("unknown journal replacement"));
}
}
Ok(())
}
pub(crate) fn verify_released_resources(
config: &ValidatedRuntimeConfig,
slot: &OwnedFd,
uid: u32,
gid: u32,
) -> io::Result<()> {
let record = read_record(slot)?;
if record.owner != Owner::current()? || record.uid != uid || record.gid != gid {
return Err(denied("identity owner record changed"));
}
let roots = Roots::open(config)?;
if record.anchors != roots.anchors()? {
return Err(denied("identity release anchors changed"));
}
// Reserve the UID until *all* fixed resource paths are gone. A failed
// capture unmount/delete must not release an identity just because the
// process group and task root have already been removed. O_PATH also
// rejects a replacement symlink/special file without opening its target.
let name = format!("kf-{}", record.run_id);
for parent in [&roots.tasks, &roots.cgroup, &roots.captures] {
match rustix::fs::openat(
parent,
name.as_str(),
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
) {
Err(rustix::io::Errno::NOENT) => {}
Err(e) => return Err(e.into()),
Ok(_) => return Err(denied("identity still owns a residual resource")),
}
}
Ok(())
}
fn mount_id(fd: &OwnedFd) -> io::Result<u64> {
std::fs::read_to_string(format!("/proc/self/fdinfo/{}", fd.as_raw_fd()))?
.lines()
.find_map(|s| s.strip_prefix("mnt_id:"))
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| denied("mount identity missing"))
}
fn same_mount(parent: &OwnedFd, fd: &OwnedFd) -> io::Result<()> {
if mount_id(parent)? != mount_id(fd)? {
return Err(denied("unexpected recovery submount"));
}
Ok(())
}
fn resource(parent: &OwnedFd, name: &str, inode: Option<Inode>) -> io::Result<Option<OwnedFd>> {
let fd = match rustix::fs::openat(parent, name, DIR, Mode::empty()) {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) => return Ok(None),
Err(e) => return Err(e.into()),
};
inode
.ok_or_else(|| denied("resource has no durable ownership binding"))?
.check(&fd)?;
private(&fd, 0o700)?;
same_mount(parent, &fd)?;
Ok(Some(fd))
}
fn remove_directory(parent: &OwnedFd, name: &str, held: &OwnedFd) -> io::Result<()> {
let named = rustix::fs::openat(parent, name, DIR, Mode::empty())?;
Inode::of(held)?.check(&named)?;
same_mount(parent, &named)?;
rustix::fs::unlinkat(parent, name, AtFlags::REMOVEDIR)?;
Ok(())
}
fn wait_event(group: &OwnedFd, key: &str, value: &str, deadline: Instant) -> io::Result<()> {
loop {
if crate::cgroup::read(group, "cgroup.events")?
.lines()
.any(|s| s.split_once(' ') == Some((key, value)))
{
return Ok(());
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"recovery group deadline",
));
}
std::thread::sleep(Duration::from_millis(2));
}
}
fn stop(group: &OwnedFd) -> io::Result<u32> {
if rustix::fs::fstatfs(group)?.f_type as u64 != 0x6367_7270
|| crate::cgroup::read(group, "cgroup.type")?.trim() != "domain"
{
return Err(denied("invalid recovery task cgroup"));
}
for name in names(group)? {
if rustix::fs::openat(group, name, DIR, Mode::empty()).is_ok() {
return Err(denied("unexpected nested task cgroup"));
}
}
let deadline = Instant::now() + Duration::from_secs(2);
crate::cgroup::write(group, "cgroup.freeze", "1")?;
wait_event(group, "frozen", "1", deadline)?;
let text = crate::cgroup::read(group, "cgroup.procs")?;
let mut fds = Vec::new();
for pid in text.split_whitespace() {
if fds.len() >= 128 {
return Err(denied("recovery process bound"));
}
let pid = pid
.parse::<i32>()
.ok()
.and_then(rustix::process::Pid::from_raw)
.ok_or_else(|| denied("invalid group member"))?;
match rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty()) {
Ok(fd) => fds.push(fd),
Err(rustix::io::Errno::SRCH) => {}
Err(e) => return Err(e.into()),
}
}
crate::cgroup::write(group, "cgroup.kill", "1")?;
wait_event(group, "populated", "0", deadline)?;
if !crate::cgroup::read(group, "cgroup.procs")?
.trim()
.is_empty()
{
return Err(denied("recovery group still populated"));
}
// Startup is not the parent of the old supervisor's descendants: do not
// wait(-1) or signal numeric PIDs. Held pidfds prove exit; their actual
// parent/system init owns zombie reaping, including across service restart.
loop {
let mut poll: Vec<_> = fds
.iter()
.map(|fd| rustix::event::PollFd::new(fd, rustix::event::PollFlags::IN))
.collect();
rustix::event::poll(
&mut poll,
Some(&rustix::event::Timespec {
tv_sec: 0,
tv_nsec: 0,
}),
)?;
if poll.iter().all(|p| {
p.revents()
.intersects(rustix::event::PollFlags::IN | rustix::event::PollFlags::HUP)
}) {
return Ok(fds.len() as u32);
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"recovery pidfd exit deadline",
));
}
std::thread::sleep(Duration::from_millis(2));
}
}
#[derive(Debug, Serialize)]
pub struct RecoveryItem {
pub uid: u32,
pub run_id: Option<String>,
pub status: &'static str,
pub stopped_processes: u32,
pub cleanup_error: Option<ExecutionError>,
}
#[derive(Debug, Default, Serialize)]
pub struct RecoveryReport {
pub items: Vec<RecoveryItem>,
}
fn reclaim(
roots: &Roots,
identities: &OwnedFd,
slot: &OwnedFd,
name: &str,
record: &Record,
stopped: &mut u32,
) -> io::Result<()> {
if record.anchors != roots.anchors()? {
return Err(denied("recovery anchor binding changed"));
}
let task_name = format!("kf-{}", record.run_id);
let group = resource(&roots.cgroup, &task_name, record.resources.cgroup)?;
let task = resource(&roots.tasks, &task_name, record.resources.task)?;
let capture = resource(&roots.captures, &task_name, record.resources.capture)?;
*stopped = if let Some(group) = &group {
stop(group)?
} else {
0
};
if let Some(group) = &group {
remove_directory(&roots.cgroup, &task_name, group)?;
}
if let Some(task) = &task {
let entries = names(task)?;
if entries.iter().any(|n| n != "root") {
return Err(denied("unknown task directory content"));
}
if entries.iter().any(|n| n == "root") {
let root = rustix::fs::openat(task, "root", DIR, Mode::empty())?;
private(&root, 0o700)?;
same_mount(task, &root)?;
if !names(&root)?.is_empty() {
return Err(denied("unexpected task root contents"));
}
remove_directory(task, "root", &root)?;
}
remove_directory(&roots.tasks, &task_name, task)?;
}
if let Some(capture) = &capture {
if !names(capture)?.is_empty() {
return Err(denied("unexpected capture contents"));
}
remove_directory(&roots.captures, &task_name, capture)?;
}
verify_release(slot)?;
for file in [".owner.next", "owner.json"] {
match rustix::fs::unlinkat(slot, file, AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => {}
Err(e) => return Err(e.into()),
}
}
remove_directory(identities, name, slot)?;
rustix::fs::fsync(identities)?;
Ok(())
}
/// Call in the fresh privileged service-start helper, before accepting runs.
/// A live owner or held directory flock is skipped; failures retain the UID.
pub fn recover(config: &ValidatedRuntimeConfig) -> io::Result<RecoveryReport> {
crate::launcher::entry::ensure_single_threaded()?;
if !rustix::process::geteuid().is_root() {
return Err(denied("privileged recovery helper required"));
}
let state = crate::isolation::root::open_directory(Path::new(&config.config().state_root))?;
let identities = match rustix::fs::openat(&state, "identities", DIR, Mode::empty()) {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) => return Ok(RecoveryReport::default()),
Err(e) => return Err(e.into()),
};
private(&identities, 0o700)?;
let roots = Roots::open(config)?;
let mut report = RecoveryReport::default();
for index in 0..config.config().max_concurrent_runs {
let (uid, gid) = config
.task_identity(index)
.ok_or_else(|| denied("recovery identity mapping"))?;
let name = format!("uid-{uid}");
let slot = match rustix::fs::openat(&identities, name.as_str(), DIR, Mode::empty()) {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) => continue,
Err(e) => {
report.items.push(RecoveryItem {
uid,
run_id: None,
status: "unrecognized",
stopped_processes: 0,
cleanup_error: Some(crate::launcher::blocked::failure(
ExecutionStage::Cleanup,
ErrorCode::CleanupIncomplete,
Some(&e.into()),
)),
});
continue;
}
};
let mut item = RecoveryItem {
uid,
run_id: None,
status: "unrecognized",
stopped_processes: 0,
cleanup_error: None,
};
let result = (|| {
private(&slot, 0o700)?;
match rustix::fs::flock(&slot, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => {}
Err(rustix::io::Errno::WOULDBLOCK) => {
item.status = "active";
return Ok(());
}
Err(e) => return Err(e.into()),
}
let Ok(record) = read_record(&slot) else {
return Ok(());
};
item.run_id = Some(record.run_id.clone());
if record.uid != uid || record.gid != gid {
return Err(denied("recorded identity mapping changed"));
}
if record.owner.alive()? {
item.status = "active";
return Ok(());
}
reclaim(
&roots,
&identities,
&slot,
&name,
&record,
&mut item.stopped_processes,
)?;
item.status = "reclaimed";
Ok::<_, io::Error>(())
})();
if let Err(e) = result {
item.status = "cleanup_failed";
item.cleanup_error = Some(crate::launcher::blocked::failure(
ExecutionStage::Cleanup,
ErrorCode::CleanupIncomplete,
Some(&e),
));
}
report.items.push(item);
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn owner_start_time_parses_parenthesized_comm_and_rejects_truncation() {
let tail = (0..20)
.map(|n| if n == 19 { "12345".into() } else { "1".into() })
.collect::<Vec<String>>()
.join(" ");
assert_eq!(
start_ticks(&format!("7 (name ) has spaces) {tail}")).unwrap(),
12345
);
for bad in ["", "7 (name) S 1", "7 name S 1"] {
assert!(start_ticks(bad).is_err());
}
}
}
@@ -180,6 +180,7 @@ impl RootLease {
};
if let Err(source) = (|| {
safe_directory(&lease.task)?;
crate::cleanup::register(config, spec, crate::cleanup::Resource::Task, &lease.task)?;
rustix::fs::mkdirat(&lease.task, "root", Mode::from_raw_mode(0o700))?;
lease.root_created = true;
Ok::<_, io::Error>(())
@@ -75,6 +75,9 @@ impl CaptureLease {
removed: false,
};
let mount = (|| {
let bare = rustix::fs::openat(&lease.parent, lease.name.as_str(), DIR, Mode::empty())?;
crate::cleanup::register(config, spec, crate::cleanup::Resource::Capture, &bare)?;
drop(bare);
let data = std::ffi::CString::new(format!("size={size},nr_inodes=16,mode=0700"))?;
rustix::mount::mount(
"tmpfs",
+23 -19
View File
@@ -8,7 +8,7 @@ use crate::runtime::ValidatedRuntimeConfig;
use crate::spec::ValidatedSpec;
use rustix::fd::OwnedFd;
use rustix::fs::{AtFlags, Mode, OFlags};
use std::{io, io::Write, path::Path};
use std::{io, path::Path};
const DIR: OFlags = OFlags::RDONLY
.union(OFlags::DIRECTORY)
@@ -26,12 +26,14 @@ fn private(fd: &OwnedFd) -> io::Result<()> {
}
struct IdentityLease {
config: ValidatedRuntimeConfig,
parent: OwnedFd,
slot: OwnedFd,
name: String,
uid: u32,
gid: u32,
removed: bool,
initialized: bool,
}
impl IdentityLease {
fn acquire(config: &ValidatedRuntimeConfig, spec: &ValidatedSpec) -> Result<Self, RootError> {
@@ -71,33 +73,20 @@ impl IdentityLease {
}
};
let mut lease = Self {
config: config.clone(),
parent,
slot,
name,
uid,
gid,
removed: false,
initialized: false,
};
let setup = (|| {
rustix::fs::fchmod(&lease.slot, Mode::from_raw_mode(0o700))?;
private(&lease.slot)?;
let marker = rustix::fs::openat(
&lease.slot,
"owner.json",
OFlags::WRONLY
| OFlags::CREATE
| OFlags::EXCL
| OFlags::CLOEXEC
| OFlags::NOFOLLOW,
Mode::from_raw_mode(0o600),
)?;
let mut marker = std::fs::File::from(marker);
serde_json::to_writer(
&mut marker,
&serde_json::json!({"version":1,"run_id":spec.spec().run_id,"uid":uid,"gid":gid}),
)?;
marker.write_all(b"\n")?;
marker.sync_all()?;
crate::cleanup::initialize_slot(config, spec, &lease.slot, uid, gid)?;
lease.initialized = true;
rustix::fs::fsync(&lease.slot)?;
rustix::fs::fsync(&lease.parent)?;
Ok::<_, io::Error>(())
@@ -124,6 +113,19 @@ impl IdentityLease {
if (a.st_dev, a.st_ino) != (b.st_dev, b.st_ino) {
return Err(denied("identity marker changed"));
}
crate::cleanup::verify_release(&self.slot)?;
if self.initialized {
crate::cleanup::verify_released_resources(
&self.config,
&self.slot,
self.uid,
self.gid,
)?;
}
match rustix::fs::unlinkat(&self.slot, ".owner.next", AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => {}
Err(e) => return Err(e.into()),
}
match rustix::fs::unlinkat(&self.slot, "owner.json", AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => {}
Err(e) => return Err(e.into()),
@@ -179,7 +181,9 @@ impl IdentityReducedTarget {
return Err(e);
}
};
if let Err(e) = target.target.root.initialize_rlimits() {
if let Err(e) = crate::cleanup::verify_owned(config, spec, false)
.and_then(|()| target.target.root.initialize_rlimits())
{
let mut e = target
.target
.root
@@ -57,13 +57,13 @@ pub(crate) fn cancel_stage(
} else {
drop(stdio);
}
drop(pump);
lease.cleanup()?;
lease.cleanup()?;
let result = t.refuse_at(crate::outcome::ExecutionStage::Exec, None);
if result.cleanup_failure.is_some() {
return Err(fail(result));
}
drop(pump);
lease.cleanup()?;
lease.cleanup()?;
}
_ => return Err(io::Error::other("fixed preparation stage")),
}
+15
View File
@@ -44,6 +44,7 @@ pub(crate) enum MonitorMode {
Normal,
CancelAfter(u64),
FailAfter(u64),
RestartReady,
}
fn capture(status: CaptureStatus, id: &str) -> OutputCapture {
OutputCapture {
@@ -103,6 +104,7 @@ pub(crate) fn execute(
let work = target.root().pin_work()?;
let (lease, dir) = CaptureLease::prepare(config, spec)?;
let (pump, stdio) = PipePump::prepare(target.take_stdin(), spec, dir)?;
crate::cleanup::verify_owned(config, spec, true)?;
target.root_mut().target.prepare_execution(spec, stdio)?;
Ok::<_, io::Error>((work, lease, pump))
})();
@@ -159,6 +161,19 @@ pub(crate) fn execute(
None
}
};
// Fixed VM crash probe only: retain a real supervisor-owned residual
// child in the same marked cgroup to exercise post-crash startup kill.
let _restart_child = if matches!(mode, MonitorMode::RestartReady) && main_fd.is_some() {
match crate::acceptance::restart::ready(config, spec, "live") {
Ok(child) => Some(child),
Err(e) => {
errors.push(error(ExecutionStage::Exec, ErrorCode::OperationFailed, &e));
None
}
}
} else {
None
};
let mut monitoring_failed = !errors.is_empty();
if !monitoring_failed {
loop {
+2
View File
@@ -21,6 +21,8 @@ pub mod io;
#[cfg(target_os = "linux")]
pub mod accounting;
#[cfg(target_os = "linux")]
pub mod cleanup;
#[cfg(target_os = "linux")]
pub mod supervisor;
#[cfg(all(target_os = "linux", feature = "vm-acceptance"))]
+7 -1
View File
@@ -77,7 +77,7 @@ fn invalid(field: &'static str, code: &'static str) -> ConfigError {
}
/// Immutable structural policy; deliberately not an execution permit.
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct ValidatedRuntimeConfig(RuntimeConfig);
impl ValidatedRuntimeConfig {
@@ -154,6 +154,12 @@ impl RuntimeConfig {
}) {
return Err(invalid("roots", "identity_lease_overlap"));
}
let capture_root = format!("{}/captures", self.state_root);
if [&self.tasks_root, &self.image_root].iter().any(|root| {
*root == &capture_root || inside(root, &capture_root) || inside(&capture_root, root)
}) {
return Err(invalid("roots", "capture_lease_overlap"));
}
if !inside(&self.delegated_cgroup_root, "/sys/fs/cgroup") {
return Err(invalid("delegated_cgroup_root", "not_delegated_subtree"));
}
+1 -1
View File
@@ -208,7 +208,7 @@ fn error(field: &'static str, code: ValidationCode) -> SpecError {
SpecError { field, code }
}
fn identifier(value: &str, field: &'static str) -> Result<(), SpecError> {
pub(crate) fn identifier(value: &str, field: &'static str) -> Result<(), SpecError> {
if value.is_empty()
|| value.len() > 64
|| !value
+387
View File
@@ -0,0 +1,387 @@
#![cfg(all(target_os = "linux", feature = "vm-acceptance"))]
#[allow(dead_code)]
mod support;
use rustix::{
fd::{AsFd, OwnedFd},
process::{WaitId, WaitIdOptions},
};
use std::{
fs,
os::unix::{
fs::{PermissionsExt, symlink},
process::ExitStatusExt,
},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
const LEASE: &str = "/var/lib/kf-judge/identities/uid-100000/owner.json";
fn command(case: &str) -> Command {
let mut c = Command::new(support::binary());
c.args(["vm-case", case])
.env_clear()
.env("KF_SANDBOX_INDEPENDENT_VM", "1");
c
}
fn recover() -> serde_json::Value {
let o = command("restart-recover").output().unwrap();
assert!(
o.status.success(),
"{} {}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
println!("KF_RESTART_RECOVERY_PROOF {v}");
v
}
fn record() -> serde_json::Value {
serde_json::from_slice(&fs::read(LEASE).unwrap()).unwrap()
}
fn store(v: &serde_json::Value) {
fs::write(LEASE, serde_json::to_vec(v).unwrap()).unwrap();
}
fn empty() {
for dir in ["tasks", "identities", "captures"] {
assert_eq!(
fs::read_dir(format!("/var/lib/kf-judge/{dir}"))
.unwrap()
.count(),
0
);
}
}
struct Owner {
child: Option<Child>,
cg: String,
publications: String,
run_id: String,
members: Vec<OwnedFd>,
previous: Option<rustix::process::Pid>,
residual: u32,
}
impl Owner {
fn start(stage: &str) -> Self {
support::require_vm();
assert!(support::children().is_empty());
let previous = rustix::process::child_subreaper().unwrap();
rustix::process::set_child_subreaper(rustix::process::Pid::from_raw(1)).unwrap();
let _ = fs::remove_file("/kf/restart-ready");
let mut child = command(&format!("restart-{stage}"))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let owner = child.id();
let cg = format!("/sys/fs/cgroup/kf-acceptance-{owner}");
let deadline = Instant::now() + Duration::from_secs(8);
let ready: serde_json::Value = loop {
assert!(
child.try_wait().unwrap().is_none(),
"supervisor exited before ready"
);
assert!(
Instant::now() < deadline,
"crash fixture readiness deadline"
);
if let Ok(bytes) = fs::read("/kf/restart-ready") {
break serde_json::from_slice(&bytes).unwrap();
}
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(ready["owner_pid"], owner);
assert_eq!(ready["stage"], stage);
let run_id = ready["run_id"].as_str().unwrap().to_owned();
if stage == "live" {
loop {
let pids = fs::read_to_string(format!("{cg}/kf-{run_id}/cgroup.procs")).unwrap();
if pids.split_whitespace().any(|p| {
fs::read(format!("/proc/{p}/root/work/peer-ready")).is_ok_and(|s| s == b"ready")
}) {
break;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(5));
}
}
let members = fs::read_to_string(format!("{cg}/kf-{run_id}/cgroup.procs"))
.unwrap()
.split_whitespace()
.map(|n| {
rustix::process::pidfd_open(
rustix::process::Pid::from_raw(n.parse().unwrap()).unwrap(),
rustix::process::PidfdFlags::empty(),
)
.unwrap()
})
.collect();
let r = record();
assert_eq!(r["owner"]["pid"], owner);
assert_eq!(r["version"], 2);
assert_eq!(r["service"], "kf-sandbox-task-v2");
for key in ["task", "cgroup", "capture"] {
assert!(r["resources"][key]["ino"].as_u64().unwrap() > 0);
}
let mut cfg: kf_sandbox::runtime::RuntimeConfig = serde_json::from_str(include_str!(
"../../../../deploy/sandbox.runtime.example.json"
))
.unwrap();
cfg.delegated_cgroup_root = cg.clone();
fs::write(
"/kf/restart-runtime.json",
serde_json::to_vec(&cfg).unwrap(),
)
.unwrap();
fs::set_permissions(
"/kf/restart-runtime.json",
fs::Permissions::from_mode(0o600),
)
.unwrap();
Self {
child: Some(child),
cg,
publications: format!("/kf/execution-publications-{owner}"),
run_id,
members,
previous,
residual: ready["residual_pid"].as_u64().unwrap() as u32,
}
}
fn crash(&mut self) {
self.child.as_mut().unwrap().kill().unwrap();
assert_eq!(self.child.take().unwrap().wait().unwrap().signal(), Some(9));
assert!(
fs::read_to_string(format!("/proc/{}/stat", self.residual)).is_ok(),
"real partial-preparation residual must survive owner SIGKILL"
);
}
fn group(&self) -> String {
format!("{}/kf-{}", self.cg, self.run_id)
}
fn task(&self) -> String {
format!("/var/lib/kf-judge/tasks/kf-{}", self.run_id)
}
fn reap(&self) {
let deadline = Instant::now() + Duration::from_secs(3);
for fd in &self.members {
loop {
match rustix::process::waitid(
WaitId::PidFd(fd.as_fd()),
WaitIdOptions::EXITED | WaitIdOptions::NOHANG,
) {
Ok(Some(_)) | Err(rustix::io::Errno::CHILD) => break,
Ok(None) => {
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(5));
}
Err(e) => panic!("owned pidfd wait: {e}"),
}
}
let mut p = [rustix::event::PollFd::new(fd, rustix::event::PollFlags::IN)];
rustix::event::poll(
&mut p,
Some(&rustix::event::Timespec {
tv_sec: 0,
tv_nsec: 0,
}),
)
.unwrap();
assert!(
p[0].revents()
.intersects(rustix::event::PollFlags::IN | rustix::event::PollFlags::HUP)
);
}
assert!(
support::children().is_empty(),
"all owned adopted children reaped"
);
}
fn finish(&self) {
self.reap();
assert!(!std::path::Path::new(&self.group()).exists());
empty();
assert_eq!(recover()["items"], serde_json::json!([]));
println!(
"KF_RESTART_KERNEL_PROOF {}",
serde_json::json!({"run_id":self.run_id,"residual_pid":self.residual,"owner_killed_sigkill":true,"kernel_pidfd_exits_verified":self.members.len(),"only_owned_adopted_children_reaped":true,"resources_removed":true,"repeat_cleanup_empty":true})
);
}
}
impl Drop for Owner {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
let _ = fs::write(format!("{}/cgroup.kill", self.cg), "1");
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
match rustix::process::wait(rustix::process::WaitOptions::NOHANG) {
Ok(Some(_)) => {}
Err(rustix::io::Errno::CHILD) => break,
_ => std::thread::sleep(Duration::from_millis(5)),
}
}
let _ = fs::remove_dir(self.group());
let _ = fs::remove_dir(&self.cg);
// Fixture input publications belong to the trusted caller, not to
// sandbox recovery. Remove only this known test-owned publication.
let _ = fs::remove_dir_all(&self.publications);
let _ = fs::remove_file("/kf/restart-ready");
let _ = fs::remove_file("/kf/restart-runtime.json");
let _ = rustix::process::set_child_subreaper(self.previous);
}
}
#[test]
#[ignore = "independent capped VM: real SIGKILL and service restart before/after user exec with surviving residual"]
fn actual_owner_death_reclaims_prepared_and_live_tasks() {
for stage in ["prepared", "live"] {
let mut owner = Owner::start(stage);
assert_eq!(recover()["items"][0]["status"], "active");
assert!(owner.child.as_mut().unwrap().try_wait().unwrap().is_none());
owner.crash();
let v = recover();
assert_eq!(v["items"][0]["status"], "reclaimed", "{v}");
assert!(v["items"][0]["stopped_processes"].as_u64().unwrap() > 0);
owner.finish();
drop(owner);
empty();
}
}
#[test]
#[ignore = "independent capped VM: failed resource deletion must preserve separate error, UID and foreign bytes"]
fn cleanup_failure_is_independent_and_reserved_until_explicit_repair() {
let mut owner = Owner::start("live");
fs::write(
format!("{}/foreign", owner.task()),
b"preserve this foreign content",
)
.unwrap();
owner.crash();
let v = recover();
assert_eq!(v["items"][0]["status"], "cleanup_failed");
assert_eq!(v["items"][0]["cleanup_error"]["stage"], "cleanup");
assert_eq!(v["items"][0]["cleanup_error"]["code"], "cleanup_incomplete");
assert_eq!(
fs::read(format!("{}/foreign", owner.task())).unwrap(),
b"preserve this foreign content"
);
assert!(std::path::Path::new(LEASE).exists());
assert!(!std::path::Path::new(&owner.group()).exists());
owner.reap();
assert_eq!(recover()["items"][0]["status"], "cleanup_failed");
fs::remove_file(format!("{}/foreign", owner.task())).unwrap();
assert_eq!(recover()["items"][0]["status"], "reclaimed");
owner.finish();
}
#[test]
#[ignore = "independent capped VM: replaced cgroup inode, symlink, boot/PID identity and unknown markers stay protected"]
fn replaced_resources_and_unrecognized_markers_are_not_adopted() {
let mut owner = Owner::start("prepared");
owner.crash();
let saved = record();
// A reused owner PID/start pair is live even without a surviving flock.
let stat = fs::read_to_string("/proc/self/stat").unwrap();
let ticks = stat
.rsplit_once(") ")
.unwrap()
.1
.split_whitespace()
.nth(19)
.unwrap()
.parse::<u64>()
.unwrap();
let mut v = saved.clone();
v["owner"]["pid"] = std::process::id().into();
v["owner"]["start_ticks"] = ticks.into();
store(&v);
assert_eq!(recover()["items"][0]["status"], "active");
store(&saved);
v = saved.clone();
v["owner"]["boot_id"] = "00000000-0000-0000-0000-000000000000".into();
store(&v);
assert_eq!(recover()["items"][0]["status"], "cleanup_failed");
store(&saved);
// Startup must inspect a foreign FIFO without reading or blocking on it.
let held_record = format!("{LEASE}.held");
fs::rename(LEASE, &held_record).unwrap();
rustix::fs::mknodat(
rustix::fs::CWD,
LEASE,
rustix::fs::FileType::Fifo,
rustix::fs::Mode::from_raw_mode(0o600),
0,
)
.unwrap();
let before = Instant::now();
assert_eq!(recover()["items"][0]["status"], "unrecognized");
assert!(before.elapsed() < Duration::from_secs(2));
fs::remove_file(LEASE).unwrap();
fs::rename(held_record, LEASE).unwrap();
v = saved.clone();
v["resources"]["cgroup"] = serde_json::Value::Null;
store(&v);
assert_eq!(recover()["items"][0]["status"], "cleanup_failed");
store(&saved);
// cgroup v2 deliberately forbids rename. The VM operator first stops and
// removes its known original fixture, then creates a genuinely different
// cgroup at the same name while retaining the old recorded inode.
fs::write(format!("{}/cgroup.kill", owner.group()), "1").unwrap();
owner.reap();
fs::remove_dir(owner.group()).unwrap();
fs::create_dir(owner.group()).unwrap();
fs::set_permissions(owner.group(), fs::Permissions::from_mode(0o700)).unwrap();
let mut foreign = command("restart-residual")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
fs::write(
format!("{}/cgroup.procs", owner.group()),
foreign.id().to_string(),
)
.unwrap();
assert_eq!(recover()["items"][0]["status"], "cleanup_failed");
assert!(foreign.try_wait().unwrap().is_none());
foreign.kill().unwrap();
foreign.wait().unwrap();
fs::remove_dir(owner.group()).unwrap();
let old_task = format!("{}-held", owner.task());
fs::rename(owner.task(), &old_task).unwrap();
fs::create_dir("/kf/protected-restart-directory").unwrap();
fs::write(
"/kf/protected-restart-directory/foreign",
b"do not traverse",
)
.unwrap();
symlink("/kf/protected-restart-directory", owner.task()).unwrap();
assert_eq!(recover()["items"][0]["status"], "cleanup_failed");
assert_eq!(
fs::read("/kf/protected-restart-directory/foreign").unwrap(),
b"do not traverse"
);
fs::remove_file(owner.task()).unwrap();
fs::rename(old_task, owner.task()).unwrap();
fs::remove_file("/kf/protected-restart-directory/foreign").unwrap();
fs::remove_dir("/kf/protected-restart-directory").unwrap();
let unknown = "/var/lib/kf-judge/identities/uid-100001";
fs::create_dir(unknown).unwrap();
fs::set_permissions(unknown, fs::Permissions::from_mode(0o700)).unwrap();
let legacy = b"{\"version\":1,\"run_id\":\"foreign\",\"uid\":100001,\"gid\":100001}";
fs::write(format!("{unknown}/owner.json"), legacy).unwrap();
fs::set_permissions(
format!("{unknown}/owner.json"),
fs::Permissions::from_mode(0o600),
)
.unwrap();
let v = recover();
assert_eq!(v["items"][0]["status"], "reclaimed");
assert_eq!(v["items"][1]["status"], "unrecognized");
assert_eq!(fs::read(format!("{unknown}/owner.json")).unwrap(), legacy);
fs::remove_file(format!("{unknown}/owner.json")).unwrap();
fs::remove_dir(unknown).unwrap();
owner.finish();
println!(
"KF_RESTART_OWNERSHIP_PROOF live_owner_pid_start=1 boot_mismatch_refused=1 unrecorded_resource_refused=1 fifo_not_opened=1 replaced_cgroup_and_process_untouched=1 symlink_not_followed=1 legacy_marker_untouched=1"
);
}
@@ -318,3 +318,21 @@ fn configuration_cannot_enable_execution_or_bypass_required_fields() {
invalid.as_object_mut().unwrap().remove("aggregate");
assert!(serde_json::from_value::<RuntimeConfig>(invalid).is_err());
}
#[test]
fn task_and_image_roots_cannot_overlap_private_capture_storage() {
for field in ["tasks", "image"] {
for path in [
"/var/lib/kf-judge/captures",
"/var/lib/kf-judge/captures/nested",
] {
let mut c = config();
if field == "tasks" {
c.tasks_root = path.into();
} else {
c.image_root = path.into();
}
assert_eq!(c.validate().unwrap_err().code, "capture_lease_overlap");
}
}
}