feat(judge): compile once and run isolated points through helper factory

This commit is contained in:
2026-10-09 04:12:22 +08:00
parent 93fb41c608
commit 8a01081cc9
19 changed files with 2336 additions and 27 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
前端使用 Vue 3、TypeScript、Element Plus 和 Vite。
当前版本为 **0.0.1**:沙箱已有受控隔离、C/C++ 编译及 native 执行;平台已实现真实身份、
不可变题目、工件及有效租约读取基础。完整提交投递、协调器调度、多测试点评测与前端业务
接入仍在开发,不能将前端预览成绩当作实际结果。
不可变题目、工件及有效租约读取基础。受控 helper 工厂与本地 C/C++ 编译一次、多测试点评测
已经接通;完整提交投递、协调器调度与前端业务接入仍在开发,不能将前端预览成绩当作实际结果。
## 工程划分
+23
View File
@@ -19,3 +19,26 @@ API 存活检查和前端预览不依赖这些容器,可以先独立启动。
官方镜像说明:[PostgreSQL](https://hub.docker.com/_/postgres)、
[RabbitMQ](https://hub.docker.com/_/rabbitmq)。
### 单机 helper 工厂
`kf-sandbox factory --config /etc/kf-judge/runtime.json` 是 Root 服务入口;
普通 Worker 使用配置中 `state_root/factory/control.sock` 的普通用户客户端。
`deploy/kf-sandbox-factory.service` 给出有限服务预算示例,安装前由可信管理员
核对固定二进制、不可写 GNU 镜像/配置、Worker UID/GID,以及**与服务自身 cgroup
分离**的 Root 所有任务委派根。工厂实际读回服务 cgroup 限额,应用/读回任务总预算;
每次编译和测试点仍须通过已有完整隔离门控。服务文件本身不证明这些条件成立。
工厂只接收有界 run ID,无排队:槽位满、重复 ID 或未知字段立即拒绝。
每次申请使用全新单线程 helper,固定继承已认证的连接与工厂生命线;
额外控制帧、断开或生命线消失会终止整棵任务树。ID 先写入有限持久记录
(最多 4096 个,跨重启拒绝重放);容量用尽需要管理员在所有任务停止、已核对
回收后安排新记录周期,不自动删除记录。清理失败保留身份租约并停止新分配;
重启只根据已记录归属和 inode 回收,未知/外来内容保留并拒绝启动。
`kf-worker judge-submission` 是受控本地适配入口,仍不消费队列。
它验证不可变清单摘要,C17/C++20 仅映射到固定 GNU 命令,编译一次;
成功产物经完整清理后封印并计算 SHA-256,每点分别申请 helper、stdin 与工作区。
期望值逐点读取,留在比较端,不传给工厂/用户文件系统。
新增锁定 `sha2 0.10.9` 仅供 Worker 验证输入及编译产物摘要;
Sandbox 同版本仅为独立 VM 测试数据生成的开发依赖,Engine 不依赖加密或 OS 库。
+28
View File
@@ -0,0 +1,28 @@
[Unit]
Description=KF bounded Linux helper factory
After=local-fs.target
[Service]
Type=exec
User=root
Group=root
# Trusted operator installs this exact binary/config and separate delegated
# task root. No caller-controlled executable, image or environment is accepted.
ExecStart=/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /usr/lib/kf-judge/kf-sandbox factory --config /etc/kf-judge/runtime.json --lifetime-seconds 3600
MemoryMax=256M
MemorySwapMax=0
TasksMax=32
CPUQuota=50%
LimitFSIZE=64M
RuntimeMaxSec=3700
TimeoutStopSec=30
# First stop the factory; guardians cancel live helpers through their normal
# cleanup path. Remaining processes are terminated at the finite stop deadline.
KillMode=mixed
Restart=no
UMask=0077
# Requires root namespace/mount/cgroup privileges. Do not set NoNewPrivileges or
# an inherited seccomp filter: the existing execution gate intentionally refuses.
[Install]
WantedBy=multi-user.target
+73
View File
@@ -125,6 +125,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -189,6 +198,35 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -241,6 +279,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -392,6 +440,7 @@ dependencies = [
"seccompiler",
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -404,6 +453,7 @@ dependencies = [
"kf-sandbox",
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -636,6 +686,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -849,6 +910,12 @@ dependencies = [
"tracing-serde",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.26"
@@ -878,6 +945,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
+29 -2
View File
@@ -52,11 +52,38 @@ kf-worker judge-once --socket /run/kf-judge/task.sock \
Worker 选取业务授权的自身发布目录,准备有界封印的期望输出,并向 Engine 注入 `ExecutionPort`。适配器构造 native 规格,仅绑定程序与 stdin,不将期望输出放入用户运行根。helper 根据 `SO_PEERCRED` 核验配置中的 Worker UID/GID;客户端核验 helper 的 root 凭据。有界私有帧与 `SCM_RIGHTS` 只传持有的目录能力和只读封印输出,收到的 FD 重新检查类型、长度、访问模式、封印和 CLOEXEC,不传任意宿主挂载路径。
helper 不接管 root 或其他身份的输入工件;每任务保留相同的隔离和预算门控。执行中客户端断开或出现额外控制数据会取消全树,清理后退出。socket 清理核对自身 inode,不删除外来替换。业务发布目录的选择与业务授权仍属于可信 Worker;文件所有权不是业务授权证明。
helper 不接管 root 或其他身份的输入工件;每任务保留相同的隔离和预算门控。执行中客户端断开会取消全树,额外控制数据归为协议错误并拒绝执行,清理后退出。socket 清理核对自身 inode,不删除外来替换。业务发布目录的选择与业务授权仍属于可信 Worker;文件所有权不是业务授权证明。
Engine 仅在真实、无基础设施错误的 exit 0 后逐字节比较输出。非零退出/信号为 RuntimeError,CPU/墙钟、内存、输出限制分别映射 TLE/MLE/OLE,取消与 SystemError 单独表达;不可用计量保持 null。汇总在 Engine 内按输入顺序确定,基础设施错误或取消停止后续执行;空用例和重复 run ID 在执行前拒绝。`judge-once` 返回结构化 verdict、用例与实际计量。新增 `serde` 直接依赖用于本地作业/领域结果类型,Engine 不引入 sandbox 或 OS 库。
首轮提供 native 单用例和编译/运行沙箱能力;平台队列消费、完整提交编排、交互题、SPJ 和部署仍未实现。`kf-worker --check` 只检查入口,不消费或执行任务。权限 helper 和镜像必须由部署方配置,Worker 不调用 sudo、不修改账号或权限。
本地已提供 native 单用例及编译一次、多测试点编排;平台队列消费、持久化协调、完整提交投递、交互题、SPJ 和部署仍未实现。`kf-worker --check` 只检查入口,不消费或执行任务。权限 helper 和镜像必须由部署方配置,Worker 不调用 sudo、不修改账号或权限。
## 编译一次、多测试点
Root 服务 `kf-sandbox factory --config /etc/kf-judge/runtime.json` 先核对有限 cgroup
预留、配置/镜像权限和已确认归属的恢复记录,逐次产生独立单线程 helper。
普通用户通过 `FactoryClient` 申请固定 run ID 的连接能力;配置、镜像、二进制、
UID 池和宿主目录由服务决定。槽位满直接拒绝;有限持久 ID 记录跨重启阻止重放。
工厂退出通过生命线取消 helper,清理失败保留租约并停止分配;服务配置见
[部署说明](../deploy/README.md)。
```sh
kf-worker judge-submission --socket /var/lib/kf-judge/factory/control.sock \
--job submission.json --artifacts /srv/kf-worker/authorized \
--staging /srv/kf-worker/staging
```
本地作业包含版本化 `manifest`、`binding` 和 `manifest_digest`,清单摘要采用共享
规范序列化。工件发布按已授权的 SHA-256 名称读取,暂存父目录由普通 Worker
持有且为 0700。暂存日志记录进程启动时间、内核启动标识和目录/文件 inode;
进程重启只回收已确认归属的旧文件,未知内容保留并拒绝继续。
Engine 的编译、运行、期望值和时钟/取消端口分离。C17/C++20 映射固定 GNU 参数,
无 shell 或用户 flags;每 attempt 编译一次,封印产物、计算摘要并完成清理后,
按清单顺序逐点申请新 helper。每点只传程序和 stdin,期望值留在 Worker 比较端。
编译失败/超限为 CE,准备、导出或清理失败为 SystemError;取消/基础设施失败停止
后续点并列出跳过原因。计量分开保存编译消耗与运行 CPU 总量、内存峰值,缺失值
保留 null;完整清单缺点不能 Accepted。`judge-submission` 返回真实报告与阶段 PID。
## 监督和恢复
+29 -4
View File
@@ -118,7 +118,7 @@ pub fn judge_submission<P: CompilationPort + RunPort + ExpectedPort + ControlPor
};
let mut cleanup_complete;
let mut failure_code;
let compilation = match compilation {
let mut compilation = match compilation {
Ok(c) => {
c.report.validate()?;
cleanup_complete = c.cleanup_complete;
@@ -142,6 +142,13 @@ pub fn judge_submission<P: CompilationPort + RunPort + ExpectedPort + ControlPor
empty_compile(CompilationStatus::SystemError)
}
};
if failure_code.is_none() && p.elapsed_us() >= m.job_wall_time_us {
failure_code = Some(FailureCode::JobDeadline);
if compilation.status != CompilationStatus::Succeeded {
compilation.status = CompilationStatus::SystemError;
compilation.program = None;
}
}
let mut cases = Vec::with_capacity(m.cases.len());
let mut run_ids = Vec::with_capacity(m.cases.len());
if let Some(program) = &compilation.program {
@@ -175,13 +182,18 @@ pub fn judge_submission<P: CompilationPort + RunPort + ExpectedPort + ControlPor
}
}
Ok(o) => {
let verdict = if matches!(o.kind, ExecutionKind::Exited { code: 0 }) {
let verdict = if o.kind == ExecutionKind::InfrastructureFailure {
Verdict::SystemError
} else {
match gate(p, m) {
Err(e) => {
failure_code = Some(e.code);
Verdict::SystemError
}
Ok(true) => Verdict::Cancelled,
Ok(false) if !matches!(o.kind, ExecutionKind::Exited { code: 0 }) => {
classify(&o, &[])
}
Ok(false) => match p.expected(c) {
Ok(bytes)
if bytes.len() as u64 == c.expected.bytes
@@ -199,8 +211,6 @@ pub fn judge_submission<P: CompilationPort + RunPort + ExpectedPort + ControlPor
}
},
}
} else {
classify(&o, &[])
};
if verdict == Verdict::SystemError && failure_code.is_none() {
failure_code = Some(FailureCode::SandboxUnavailable);
@@ -306,6 +316,7 @@ mod tests {
expected_calls: usize,
cancel_after: usize,
clock: u64,
advance_us: u64,
fail_compile: bool,
}
impl CompilationPort for Port {
@@ -353,6 +364,7 @@ mod tests {
impl ControlPort for Port {
fn elapsed_us(&self) -> u64 {
self.clock
.saturating_add(self.calls as u64 * self.advance_us)
}
fn cancelled(&self) -> bool {
self.calls >= self.cancel_after
@@ -389,6 +401,7 @@ mod tests {
expected_calls: 0,
cancel_after: usize::MAX,
clock: 0,
advance_us: 0,
fail_compile: false,
}
}
@@ -527,4 +540,16 @@ mod tests {
assert!(judge_submission(&mut p, &m, b, d).is_err());
assert_eq!(p.compile_calls, 1);
}
#[test]
fn deadline_interrupt_is_infrastructure_and_overrides_cancel_kind() {
let (m, b, d) = fixture();
let mut p = port(vec![ExecutionKind::Cancelled]);
p.advance_us = m.job_wall_time_us;
let r = judge_submission(&mut p, &m, b, d).unwrap();
assert_eq!(r.report.verdict, Verdict::SystemError);
assert_eq!(r.report.failure_code, Some(FailureCode::JobDeadline));
assert_eq!(p.calls, 1);
assert_eq!(p.expected_calls, 0);
assert_eq!(r.skipped.len(), 2);
}
}
+3
View File
@@ -29,3 +29,6 @@ workspace = true
[[example]]
name = "identity_probe"
test = false
[dev-dependencies]
sha2 = "=0.10.9"
+314
View File
@@ -363,3 +363,317 @@ fn snapshot(
budget.remaining_bytes -= bytes;
Ok(SnapshotFile { file, bytes })
}
/// Ordinary Worker staging, distinct from immutable hidden-data publication.
/// Fixed entries only; no recursive cleanup or adoption of existing directories.
pub struct StagePublication {
root: ArtifactRoot,
parent: OwnedFd,
name: String,
dev: u64,
ino: u64,
files: Vec<StageEntry>,
removed: bool,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StageEntry {
name: String,
dev: u64,
ino: u64,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StageOwner {
pid: u32,
ticks: u64,
boot: String,
dev: u64,
ino: u64,
}
fn stage_ticks(value: &str) -> io::Result<u64> {
value
.rsplit_once(") ")
.and_then(|(_, s)| s.split_whitespace().nth(19))
.and_then(|s| s.parse::<u64>().ok())
.filter(|v| *v > 0)
.ok_or_else(|| refused("stage owner time"))
}
impl StagePublication {
pub fn create(parent_path: &std::path::Path, name: &str) -> io::Result<Self> {
crate::spec::identifier(name, "stage").map_err(|_| refused("stage identifier"))?;
let parent = rustix::fs::open(
parent_path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let st = rustix::fs::fstat(&parent)?;
if st.st_uid != rustix::process::geteuid().as_raw()
|| st.st_uid < 1000
|| st.st_mode & 0o7777 != 0o700
{
return Err(refused("private ordinary staging parent"));
}
rustix::fs::mkdirat(&parent, name, Mode::from_raw_mode(0o700))?;
let fd = rustix::fs::openat(
&parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let st = rustix::fs::fstat(&fd)?;
let value = Self {
root: ArtifactRoot::from_directory(fd)?,
parent,
name: name.into(),
dev: st.st_dev,
ino: st.st_ino,
files: vec![],
removed: false,
};
let header = StageOwner {
pid: std::process::id(),
ticks: stage_ticks(&std::fs::read_to_string("/proc/self/stat")?)?,
boot: std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?
.trim()
.into(),
dev: st.st_dev,
ino: st.st_ino,
};
let journal = rustix::fs::openat(
&value.root.fd,
".owner",
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
)?;
let mut file = File::from(journal);
serde_json::to_writer(&mut file, &header)?;
file.write_all(b"\n")?;
file.sync_all()?;
rustix::fs::fsync(&value.root.fd)?;
Ok(value)
}
/// Startup recovery authenticates both journal and inode. A reused PID or
/// foreign object cannot confer authority; unknown content is preserved.
pub fn recover(parent_path: &std::path::Path) -> io::Result<u32> {
use rustix::fd::AsRawFd;
let parent = rustix::fs::open(
parent_path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let st = rustix::fs::fstat(&parent)?;
if st.st_uid != rustix::process::geteuid().as_raw()
|| st.st_uid < 1000
|| st.st_mode & 0o7777 != 0o700
{
return Err(refused("ordinary recovery parent"));
}
let names: Vec<_> = std::fs::read_dir(format!("/proc/self/fd/{}", parent.as_raw_fd()))?
.take(513)
.map(|e| {
e?.file_name()
.into_string()
.map_err(|_| refused("stage name"))
})
.collect::<io::Result<_>>()?;
if names.len() > 512 {
return Err(refused("stage recovery count"));
}
let boot = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?;
let mut recovered = 0;
for name in names {
crate::spec::identifier(&name, "stage")
.map_err(|_| refused("unrecognized staging entry retained"))?;
let fd = rustix::fs::openat(
&parent,
name.as_str(),
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let root = ArtifactRoot::from_directory(fd)?;
let st = rustix::fs::fstat(&root.fd)?;
let journal = rustix::fs::openat(
&root.fd,
".owner",
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let j = rustix::fs::fstat(&journal)?;
if j.st_uid != root.owner
|| j.st_mode & 0o7777 != 0o600
|| j.st_nlink != 1
|| j.st_size < 0
|| j.st_size > 2048
|| FileType::from_raw_mode(j.st_mode) != FileType::RegularFile
{
return Err(refused("untrusted stage journal"));
}
let mut bytes = String::new();
readonly_handle(&journal)?
.take(2049)
.read_to_string(&mut bytes)?;
if bytes.len() > 2048 || !bytes.ends_with('\n') {
return Err(refused("stage journal framing"));
}
let mut lines = bytes.lines();
let owner: StageOwner =
serde_json::from_str(lines.next().ok_or_else(|| refused("stage journal"))?)
.map_err(|_| refused("stage journal"))?;
if owner.boot != boot.trim()
|| owner.pid < 2
|| owner.dev != st.st_dev
|| owner.ino != st.st_ino
{
return Err(refused("stage ownership binding"));
}
match std::fs::read_to_string(format!("/proc/{}/stat", owner.pid)) {
Ok(stat) if stage_ticks(&stat)? == owner.ticks => continue,
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let entries = lines
.map(|l| {
serde_json::from_str::<StageEntry>(l)
.map_err(|_| refused("stage entry journal"))
})
.collect::<io::Result<Vec<_>>>()?;
let mut unique = std::collections::HashSet::new();
if entries.len() > 2
|| entries.iter().any(|e| {
!matches!(e.name.as_str(), "source" | "program" | "stdin")
|| !unique.insert(e.name.clone())
})
{
return Err(refused("stage entry set"));
}
let mut value = Self {
root,
parent: rustix::io::fcntl_dupfd_cloexec(&parent, 3)?,
name,
dev: st.st_dev,
ino: st.st_ino,
files: entries,
removed: false,
};
value.cleanup()?;
recovered += 1;
}
Ok(recovered)
}
pub fn root(&self) -> &ArtifactRoot {
&self.root
}
pub fn put(&mut self, name: &str, reader: &mut impl Read, bytes: u64) -> io::Result<()> {
if !matches!(name, "source" | "program" | "stdin")
|| self.files.len() >= 2
|| bytes > MAX_SNAPSHOT_BYTES
{
return Err(refused("fixed staging entry/budget"));
}
let fd = rustix::fs::openat(
&self.root.fd,
name,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
)?;
let s = rustix::fs::fstat(&fd)?;
let entry = StageEntry {
name: name.into(),
dev: s.st_dev,
ino: s.st_ino,
};
let journal = rustix::fs::openat(
&self.root.fd,
".owner",
OFlags::WRONLY | OFlags::APPEND | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let mut file = File::from(journal);
serde_json::to_writer(&mut file, &entry)?;
file.write_all(b"\n")?;
file.sync_all()?;
self.files.push(entry);
let mut file = File::from(fd);
let copied = io::copy(&mut reader.take(bytes + 1), &mut file)?;
if copied != bytes {
return Err(refused("staged artifact byte count"));
}
file.sync_all()
}
pub fn cleanup(&mut self) -> io::Result<()> {
if self.removed {
return Ok(());
}
let named = rustix::fs::statat(
&self.parent,
self.name.as_str(),
rustix::fs::AtFlags::SYMLINK_NOFOLLOW,
)?;
if named.st_dev != self.dev || named.st_ino != self.ino {
return Err(refused("foreign staging replacement"));
}
use rustix::fd::AsRawFd;
let actual: Vec<_> =
std::fs::read_dir(format!("/proc/self/fd/{}", self.root.fd.as_raw_fd()))?
.take(5)
.map(|e| Ok(e?.file_name()))
.collect::<io::Result<_>>()?;
if actual.len() != self.files.len() + 1
|| actual
.iter()
.any(|n| n != ".owner" && !self.files.iter().any(|f| n == f.name.as_str()))
{
return Err(refused("foreign staging entry retained"));
}
for entry in &self.files {
let st = rustix::fs::statat(
&self.root.fd,
entry.name.as_str(),
rustix::fs::AtFlags::SYMLINK_NOFOLLOW,
)?;
if st.st_dev != entry.dev
|| st.st_ino != entry.ino
|| st.st_uid != self.root.owner
|| st.st_nlink != 1
|| FileType::from_raw_mode(st.st_mode) != FileType::RegularFile
{
return Err(refused("foreign staging content"));
}
}
let journal = rustix::fs::statat(
&self.root.fd,
".owner",
rustix::fs::AtFlags::SYMLINK_NOFOLLOW,
)?;
if journal.st_uid != self.root.owner
|| journal.st_nlink != 1
|| journal.st_mode & 0o7777 != 0o600
|| FileType::from_raw_mode(journal.st_mode) != FileType::RegularFile
{
return Err(refused("foreign staging journal"));
}
for entry in &self.files {
rustix::fs::unlinkat(
&self.root.fd,
entry.name.as_str(),
rustix::fs::AtFlags::empty(),
)?;
}
rustix::fs::unlinkat(&self.root.fd, ".owner", rustix::fs::AtFlags::empty())?;
self.files.clear();
rustix::fs::unlinkat(
&self.parent,
self.name.as_str(),
rustix::fs::AtFlags::REMOVEDIR,
)?;
self.removed = true;
Ok(())
}
}
impl Drop for StagePublication {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ fn read_pinned(fd: OwnedFd, limit: u64) -> io::Result<Vec<u8>> {
Ok(bytes)
}
pub fn read_bounded_file(path: &Path, limit: u64) -> io::Result<Vec<u8>> {
if limit == 0 || limit > crate::spec::MAX_SPEC_BYTES {
if limit == 0 || limit > 262_144 {
return Err(invalid("bounded JSON ceiling"));
}
let fd = rustix::fs::open(
+448
View File
@@ -0,0 +1,448 @@
//! Root service -> fresh single-threaded helper. Only a run identifier crosses
//! the allocation protocol. The accepted kernel-authenticated connection is the
//! capability, so the client never selects a helper path, executable or config.
use crate::{
artifacts::ArtifactRoot, execution, launcher::protocol::WireMessage, rpc, spec::ExecutionSpec,
};
use rustix::{
fd::OwnedFd,
fs::{Mode, OFlags},
};
use serde::{Deserialize, Serialize};
use std::{
collections::HashSet,
fs::File,
io::{self, Read, Write},
os::unix::{
fs::{FileTypeExt, MetadataExt, PermissionsExt},
net::{UnixListener, UnixStream},
},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
const MAX_IDS: usize = 4096;
fn denied() -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, "helper factory refused")
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Acquire {
run_id: String,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)]
enum Allocation {
Ready { run_id: String, helper_pid: u32 },
Refused {},
}
impl crate::launcher::protocol::sealed::Sealed for Acquire {}
impl crate::launcher::protocol::sealed::Sealed for Allocation {}
impl WireMessage for Acquire {
fn valid(&self) -> bool {
crate::spec::identifier(&self.run_id, "run_id").is_ok()
}
}
impl WireMessage for Allocation {
fn valid(&self) -> bool {
match self {
Self::Ready { run_id, helper_pid } => {
*helper_pid > 1 && crate::spec::identifier(run_id, "run_id").is_ok()
}
Self::Refused {} => true,
}
}
}
pub struct FactoryClient {
socket: PathBuf,
}
impl FactoryClient {
pub fn new(socket: PathBuf) -> Self {
Self { socket }
}
/// The caller holds only ordinary artifact and Unix socket capabilities.
pub fn execute(
&self,
spec: ExecutionSpec,
publication: &ArtifactRoot,
) -> io::Result<(rpc::Executed, u32)> {
self.execute_controlled(spec, publication, None, None)
}
pub fn execute_controlled(
&self,
spec: ExecutionSpec,
publication: &ArtifactRoot,
deadline: Option<Instant>,
cancellation: Option<&std::sync::atomic::AtomicBool>,
) -> io::Result<(rpc::Executed, u32)> {
if rustix::process::geteuid().as_raw() < 1000 || rustix::process::getegid().as_raw() < 1000
{
return Err(denied());
}
let validated = spec
.clone()
.validate(&crate::spec::SpecPolicy::default())
.map_err(|_| denied())?;
let stream = UnixStream::connect(&self.socket)?;
let interrupter = stream.try_clone()?;
let done = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|scope| {
let watch = scope.spawn(|| {
while !done.load(std::sync::atomic::Ordering::Acquire) {
if deadline.is_some_and(|d| Instant::now() >= d)
|| cancellation
.is_some_and(|c| c.load(std::sync::atomic::Ordering::Acquire))
{
// Half close is an authenticated cancellation. Preserve the
// read side for the final report and sealed cleanup proof.
let _ = interrupter.shutdown(std::net::Shutdown::Write);
break;
}
std::thread::sleep(Duration::from_millis(2));
}
});
let result = (|| {
let mut channel = rpc::channel(stream, 0, 0)?;
channel.send(&Acquire {
run_id: spec.run_id.clone(),
})?;
let pid = match channel.receive::<Allocation>()? {
Allocation::Ready { run_id, helper_pid } if run_id == spec.run_id => helper_pid,
_ => return Err(denied()),
};
Ok((
rpc::execute_channel(channel, spec, publication, validated)?,
pid,
))
})();
done.store(true, std::sync::atomic::Ordering::Release);
watch.join().map_err(|_| denied())?;
result
})
}
}
struct SocketLease {
path: PathBuf,
dev: u64,
ino: u64,
}
impl Drop for SocketLease {
fn drop(&mut self) {
if let Ok(m) = std::fs::symlink_metadata(&self.path)
&& m.dev() == self.dev
&& m.ino() == self.ino
{
let _ = std::fs::remove_file(&self.path);
}
}
}
struct Active {
child: Child,
_guardian: UnixStream,
}
fn private_file(path: &Path) -> io::Result<File> {
let fd = match rustix::fs::open(
path,
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
) {
Ok(pin) => {
let st = rustix::fs::fstat(&pin)?;
if rustix::fs::FileType::from_raw_mode(st.st_mode) != rustix::fs::FileType::RegularFile
{
return Err(denied());
}
use rustix::fd::AsRawFd;
rustix::fs::open(
format!("/proc/self/fd/{}", pin.as_raw_fd()),
OFlags::RDWR | OFlags::CLOEXEC,
Mode::empty(),
)?
}
Err(rustix::io::Errno::NOENT) => rustix::fs::open(
path,
OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
)?,
Err(e) => return Err(e.into()),
};
let s = rustix::fs::fstat(&fd)?;
if s.st_uid != 0
|| s.st_gid != 0
|| s.st_mode & 0o7777 != 0o600
|| s.st_nlink != 1
|| rustix::fs::FileType::from_raw_mode(s.st_mode) != rustix::fs::FileType::RegularFile
{
return Err(denied());
}
Ok(File::from(fd))
}
fn idle_recovery(config: &crate::runtime::ValidatedRuntimeConfig) -> io::Result<()> {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let report = crate::cleanup::recover(config)?;
if report
.items
.iter()
.any(|r| !matches!(r.status, "active" | "reclaimed"))
{
return Err(denied());
}
if !report.items.iter().any(|r| r.status == "active") {
return Ok(());
}
if Instant::now() >= deadline {
return Err(denied());
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// Fixed executable/config come solely from the trusted service invocation.
/// Runtime is finite even when the service manager is misconfigured; cgroup
/// reservations are read back by authorize, and tasks retain their own gates.
pub fn serve(config_path: &Path, lifetime_seconds: u64) -> io::Result<()> {
if !(1..=86400).contains(&lifetime_seconds) {
return Err(denied());
}
// Root management receives no ambient business/credential environment.
if std::env::vars_os().any(|(k, v)| {
!matches!(
(k.to_str(), v.to_str()),
(Some("PATH"), Some("/usr/bin:/bin"))
| (Some("LC_ALL" | "LANG"), Some("C" | "C.UTF-8"))
| (Some("TZ"), Some("UTC"))
)
}) {
return Err(denied());
}
let config = execution::load_config(config_path)?;
execution::authorize(&config)?;
let policy = config.config();
if policy.aggregate.supervisor_processes < 4 + 8 * policy.max_concurrent_runs {
return Err(denied());
}
let binary = std::env::current_exe()?;
// Pin and validate the service binary; Worker cannot replace its ancestors.
let parent = crate::isolation::root::open_directory(binary.parent().ok_or_else(denied)?)?;
let fd = rustix::fs::openat(
&parent,
binary.file_name().ok_or_else(denied)?,
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let st = rustix::fs::fstat(&fd)?;
if st.st_uid != 0
|| st.st_gid != 0
|| st.st_mode & 0o7022 != 0
|| rustix::fs::FileType::from_raw_mode(st.st_mode) != rustix::fs::FileType::RegularFile
{
return Err(denied());
}
let root = Path::new(&policy.state_root).join("factory");
if !root.exists() {
std::fs::create_dir(&root)?;
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o711))?;
}
let root_fd = crate::isolation::root::open_directory(&root)?;
let lock = private_file(&root.join("lock"))?;
rustix::fs::flock(&lock, rustix::fs::FlockOperation::NonBlockingLockExclusive)?;
// Previous live children cancel through their guardian connection. Durable
// task journals alone authorize recovery; foreign entries fail closed.
idle_recovery(&config)?;
let socket = root.join("control.sock");
let record_path = root.join("socket.json");
if let Ok(m) = std::fs::symlink_metadata(&socket) {
let bytes = execution::read_bounded_file(&record_path, 1024)?;
let record: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| denied())?;
let trusted = std::fs::symlink_metadata(&record_path)?;
if trusted.uid() != 0
|| trusted.gid() != 0
|| trusted.mode() & 0o7777 != 0o600
|| trusted.nlink() != 1
|| !trusted.is_file()
|| !m.file_type().is_socket()
|| m.uid() != 0
|| record != serde_json::json!({"dev":m.dev(),"ino":m.ino()})
{
return Err(denied());
}
std::fs::remove_file(&socket)?;
}
let mut ledger = private_file(&root.join("requests"))?;
if ledger.metadata()?.len() > (MAX_IDS * 65) as u64 {
return Err(denied());
}
let mut old = String::new();
(&mut ledger)
.take((MAX_IDS * 65 + 1) as u64)
.read_to_string(&mut old)?;
let mut ids = HashSet::new();
for id in old.lines() {
if crate::spec::identifier(id, "run_id").is_err() || !ids.insert(id.to_owned()) {
return Err(denied());
}
}
if ids.len() > MAX_IDS || (!old.is_empty() && !old.ends_with('\n')) {
return Err(denied());
}
let listener = UnixListener::bind(&socket)?;
let m = std::fs::symlink_metadata(&socket)?;
let _lease = SocketLease {
path: socket.clone(),
dev: m.dev(),
ino: m.ino(),
};
let record = serde_json::to_vec(&serde_json::json!({"dev":m.dev(),"ino":m.ino()}))?;
let mut f = private_file(&record_path)?;
f.set_len(0)?;
f.write_all(&record)?;
f.sync_all()?;
rustix::fs::fsync(&root_fd)?;
std::os::unix::fs::chown(&socket, Some(0), Some(policy.identities.worker_gid))?;
std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o660))?;
listener.set_nonblocking(true)?;
// Actual overall task delegation limits, including concurrency and no swap.
let _delegated = crate::cgroup::DelegatedCgroup::open(&config).map_err(|_| denied())?;
let mut active: Vec<Active> = Vec::new();
let deadline = Instant::now() + Duration::from_secs(lifetime_seconds);
let result = (|| {
while Instant::now() < deadline {
let mut i = 0;
while i < active.len() {
if let Some(status) = active[i].child.try_wait()? {
active.swap_remove(i);
// A failed helper may have durable residuals. Never start
// another task until recovery proves safe release.
if !status.success() {
idle_recovery(&config)?;
}
} else {
i += 1;
}
}
let stream = match listener.accept() {
Ok((s, _)) => s,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(e) => return Err(e),
};
let peer = stream.try_clone()?;
let Ok(mut channel) = rpc::channel(
stream,
policy.identities.worker_uid,
policy.identities.worker_gid,
) else {
continue;
};
let Ok(request) = channel.receive::<Acquire>() else {
continue;
};
if active.len() >= policy.max_concurrent_runs as usize
|| ids.len() >= MAX_IDS
|| ids.contains(&request.run_id)
{
let _ = channel.send(&Allocation::Refused {});
continue;
}
// Persist before allocating; IDs remain consumed across restarts.
writeln!(ledger, "{}", request.run_id)?;
ledger.sync_all()?;
ids.insert(request.run_id.clone());
let (guardian, child_guardian) = UnixStream::pair()?;
let child = Command::new(&binary)
.env_clear()
.args(["factory-child", "--config"])
.arg(config_path)
.arg("--run-id")
.arg(&request.run_id)
.stdin(Stdio::from(OwnedFd::from(peer)))
.stdout(Stdio::from(OwnedFd::from(child_guardian)))
.stderr(Stdio::null())
.spawn()?;
let helper_pid = child.id();
active.push(Active {
child,
_guardian: guardian,
});
if channel
.send(&Allocation::Ready {
run_id: request.run_id,
helper_pid,
})
.is_err()
{
// Helper receives EOF, or times out within its fixed deadline.
}
}
Ok(())
})();
drop(listener);
// Guardian shutdown initiates the same bounded task-tree termination.
for a in &active {
let _ = a._guardian.shutdown(std::net::Shutdown::Both);
}
let stop_deadline = Instant::now() + Duration::from_secs(20);
while !active.is_empty() && Instant::now() < stop_deadline {
let mut i = 0;
while i < active.len() {
if active[i].child.try_wait()?.is_some() {
active.swap_remove(i);
} else {
i += 1;
}
}
std::thread::sleep(Duration::from_millis(5));
}
if !active.is_empty() {
return Err(denied());
}
idle_recovery(&config)?;
result
}
/// Hidden child role: fixed accepted socket on stdin, factory lifeline on stdout.
/// No credentials, path selection, ambient stdio or previous task state survive.
pub fn child(config_path: &Path, run_id: &str) -> io::Result<()> {
// Duplicate only the fixed descriptors installed by the root factory.
let stream: UnixStream = rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdin(), 3)?.into();
let guardian = rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdout(), 3)?;
let peer = rustix::net::sockopt::socket_peercred(&guardian)?;
if peer.uid.as_raw() != 0
|| peer.gid.as_raw() != 0
|| peer.pid != rustix::process::getppid().ok_or_else(denied)?
{
return Err(denied());
}
let config = execution::load_config(config_path)?;
execution::authorize(&config)?;
if crate::spec::identifier(run_id, "run_id").is_err() {
return Err(denied());
}
let result = rpc::serve_stream(&config, stream, Some(run_id), Some(&guardian))?;
if result["report"]["cleanup"] != "complete" {
return Err(denied());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn protocol_never_accepts_worker_control_of_root_command_or_path() {
for data in [
r#"{"run_id":"a","argv":["/bin/sh"]}"#,
r#"{"run_id":"a","config":"/tmp/x"}"#,
r#"{"run_id":"../x"}"#,
r#"{"run_id":""}"#,
] {
let decoded = serde_json::from_str::<Acquire>(data);
assert!(decoded.is_err() || !decoded.unwrap().valid());
}
assert!(
serde_json::from_str::<Acquire>(r#"{"run_id":"a-1"}"#)
.unwrap()
.valid()
);
}
}
+51 -10
View File
@@ -41,6 +41,7 @@ pub(crate) struct RunProof {
#[derive(Clone, Copy)]
pub(crate) enum MonitorMode<'a> {
Peer(&'a rustix::fd::OwnedFd),
FactoryPeers(&'a rustix::fd::OwnedFd, &'a rustix::fd::OwnedFd),
Normal,
#[cfg(feature = "vm-acceptance")]
CancelAfter(u64),
@@ -79,7 +80,7 @@ fn consume(event: BudgetEvent) -> (ResourceUsage, Vec<LimitObservation>) {
}
}
fn peer_closed(fd: &rustix::fd::OwnedFd) -> io::Result<bool> {
pub(crate) fn peer_closed(fd: &rustix::fd::OwnedFd) -> io::Result<bool> {
use rustix::event::{PollFd, PollFlags, poll};
let mut f = [PollFd::new(
fd,
@@ -92,7 +93,23 @@ fn peer_closed(fd: &rustix::fd::OwnedFd) -> io::Result<bool> {
tv_nsec: 0,
}),
)?;
Ok(!f[0].revents().is_empty())
if f[0].revents().is_empty() {
return Ok(false);
}
let mut byte = [0u8; 1];
match rustix::net::recv(
fd,
&mut byte,
rustix::net::RecvFlags::PEEK | rustix::net::RecvFlags::DONTWAIT,
) {
Ok((0, _)) => Ok(true),
Ok(_) => Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpected control bytes during execution",
)),
Err(rustix::io::Errno::AGAIN) => Ok(false),
Err(e) => Err(e.into()),
}
}
pub(crate) fn execute(
config: &ValidatedRuntimeConfig,
@@ -157,9 +174,11 @@ pub(crate) fn execute(
});
}
let released = (|| {
if let MonitorMode::Peer(fd) = mode
&& peer_closed(fd)?
{
if match mode {
MonitorMode::Peer(fd) => peer_closed(fd)?,
MonitorMode::FactoryPeers(a, b) => peer_closed(a)? || peer_closed(b)?,
_ => false,
} {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"client disconnected before exec",
@@ -174,6 +193,10 @@ pub(crate) fn execute(
})();
let main_fd = match released {
Ok(fd) => Some(fd),
Err(e) if e.kind() == io::ErrorKind::Interrupted => {
cancelled = Some(0);
None
}
Err(e) => {
errors.push(error(ExecutionStage::Exec, ErrorCode::ProtocolFailed, &e));
None
@@ -194,7 +217,7 @@ pub(crate) fn execute(
None
};
let mut monitoring_failed = !errors.is_empty();
if !monitoring_failed {
if !monitoring_failed && cancelled.is_none() {
loop {
let event = match budget.poll() {
Ok(e) => e,
@@ -275,10 +298,21 @@ pub(crate) fn execute(
monitoring_failed = true;
}
}
match mode {
MonitorMode::Peer(fd) if peer_closed(fd).unwrap_or(true) => {
cancelled = Some(wall(&usage));
let connection = match mode {
MonitorMode::Peer(fd) => peer_closed(fd),
MonitorMode::FactoryPeers(a, b) => peer_closed(a)
.and_then(|closed| if closed { Ok(true) } else { peer_closed(b) }),
_ => Ok(false),
};
match connection {
Ok(true) => cancelled = Some(wall(&usage)),
Err(e) => {
errors.push(error(ExecutionStage::Wait, ErrorCode::ProtocolFailed, &e));
monitoring_failed = true;
}
Ok(false) => {}
}
match mode {
#[cfg(feature = "vm-acceptance")]
MonitorMode::CancelAfter(t) if wall(&usage) >= t => {
cancelled = Some(wall(&usage))
@@ -476,7 +510,14 @@ pub(crate) fn execute(
}
let stdout = SnapshotFile::from_capture(pump.stdout());
let stderr = SnapshotFile::from_capture(pump.stderr());
let outputs = if errors.iter().any(|e| e.stage == ExecutionStage::Cleanup) {
let compile_failed = matches!(
spec.spec().profile,
crate::spec::ExecutionProfile::CompileC | crate::spec::ExecutionProfile::CompileCpp
) && (main != Some(Termination::Exited { code: 0 })
|| cancelled.is_some()
|| !limits.is_empty()
|| !errors.is_empty());
let outputs = if compile_failed || errors.iter().any(|e| e.stage == ExecutionStage::Cleanup) {
Vec::new()
} else {
match target.root().final_outputs(&work) {
+3
View File
@@ -33,3 +33,6 @@ pub mod execution;
#[cfg(target_os = "linux")]
pub mod rpc;
#[cfg(target_os = "linux")]
pub mod factory;
+42
View File
@@ -47,6 +47,21 @@ enum Command {
#[arg(long)]
socket: PathBuf,
},
/// Root factory: finite service lifetime and fresh authenticated helpers.
Factory {
#[arg(long)]
config: PathBuf,
#[arg(long, default_value_t = 3600)]
lifetime_seconds: u64,
},
#[cfg(target_os = "linux")]
#[command(hide = true)]
FactoryChild {
#[arg(long)]
config: PathBuf,
#[arg(long)]
run_id: String,
},
/// Internal trusted launcher control entry; never runs a user program.
#[cfg(target_os = "linux")]
#[command(hide = true)]
@@ -142,6 +157,33 @@ fn main() -> ExitCode {
spec_error("unsupported_platform", None)
}
}
Command::Factory {
config,
lifetime_seconds,
} => {
#[cfg(target_os = "linux")]
{
if kf_sandbox::factory::serve(&config, lifetime_seconds).is_ok() {
ExitCode::SUCCESS
} else {
spec_error("factory_refused", None)
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (config, lifetime_seconds);
spec_error("unsupported_platform", None)
}
}
#[cfg(target_os = "linux")]
Command::FactoryChild { config, run_id } => {
// stdout is the root factory lifeline, never a log stream.
if kf_sandbox::factory::child(&config, &run_id).is_ok() {
ExitCode::SUCCESS
} else {
ExitCode::from(2)
}
}
Command::Validate { spec } => validate_spec(spec),
Command::CheckRuntime { config } => check_runtime(config),
#[cfg(target_os = "linux")]
+33 -8
View File
@@ -64,7 +64,7 @@ impl WireMessage for Reply {
}
}
}
fn channel(stream: UnixStream, uid: u32, gid: u32) -> io::Result<ControlChannel> {
pub(crate) fn channel(stream: UnixStream, uid: u32, gid: u32) -> io::Result<ControlChannel> {
let c = rustix::net::sockopt::socket_peercred(&stream)?;
ControlChannel::new(
stream,
@@ -106,7 +106,15 @@ pub fn execute(
.clone()
.validate(&SpecPolicy::default())
.map_err(|_| denied())?;
let mut c = channel(UnixStream::connect(socket)?, 0, 0)?;
let c = channel(UnixStream::connect(socket)?, 0, 0)?;
execute_channel(c, spec, publication, validated)
}
pub(crate) fn execute_channel(
mut c: ControlChannel,
spec: ExecutionSpec,
publication: &ArtifactRoot,
validated: crate::spec::ValidatedSpec,
) -> io::Result<Executed> {
c.send(&Request { spec })?;
c.send_fd(publication)?;
c.execution_deadline(validated.spec().limits.wall_time_us)?;
@@ -205,27 +213,44 @@ pub fn serve_one(config_path: &Path, socket: &Path) -> io::Result<serde_json::Va
}
};
drop(listener);
serve_stream(&config, stream, None, None)
}
pub(crate) fn serve_stream(
config: &crate::runtime::ValidatedRuntimeConfig,
stream: UnixStream,
binding: Option<&str>,
guardian: Option<&OwnedFd>,
) -> io::Result<serde_json::Value> {
let ids = &config.config().identities;
let mut c = channel(stream, ids.worker_uid, ids.worker_gid)?;
let request = c.receive::<Request>()?;
if binding.is_some_and(|id| id != request.spec.run_id) {
return Err(denied());
}
let artifacts = ArtifactRoot::from_worker_directory(c.receive_fd()?, ids.worker_uid)?;
let peer = c.duplicate_stream()?;
c.execution_deadline(request.spec.limits.wall_time_us)?;
match execution::execute(
&config,
config,
request.spec,
&artifacts,
run::MonitorMode::Peer(&peer),
guardian.map_or(run::MonitorMode::Peer(&peer), |g| {
run::MonitorMode::FactoryPeers(&peer, g)
}),
) {
Ok((result, _)) => {
let report = result.report;
// Cleanup failures remain in the report; caller may never claim a successful verdict.
if report.cancelled_at_wall_time_us.is_some() {
if report.cleanup != crate::outcome::CleanupState::Complete {
return Err(denied());
}
let sent = c.send(&Reply::Completed {
report: Box::new(report.clone()),
});
if sent.is_err() && report.cancelled_at_wall_time_us.is_some() {
return Ok(serde_json::json!({"report":report,"proof":result.proof}));
}
c.send(&Reply::Completed {
report: Box::new(report.clone()),
})?;
sent?;
c.send_fd(&result.stdout)?;
c.send_fd(&result.stderr)?;
for o in result.outputs {
+714
View File
@@ -0,0 +1,714 @@
#![cfg(all(target_os = "linux", feature = "vm-acceptance"))]
#[allow(dead_code)]
mod support;
use kf_sandbox::{
artifacts::ArtifactRoot,
factory::FactoryClient,
outcome::{CleanupState, ExecutionOutcome},
spec::ExecutionSpec,
};
use sha2::{Digest as _, Sha256};
use std::{
fs,
io::{Read, Write},
os::unix::{
fs::{PermissionsExt, chown},
net::UnixStream,
process::CommandExt,
},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::atomic::{AtomicBool, AtomicU32, Ordering},
time::{Duration, Instant},
};
static COUNT: AtomicU32 = AtomicU32::new(0);
const SOURCE:&[u8]=br#"#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main(void){ unsigned char c; FILE *f=fopen("previous","rb"); if(f) return 91; f=fopen("previous","wb"); if(!f)return 92; fclose(f); if(access("expected",F_OK)==0)return 93; while(fread(&c,1,1,stdin)){if(c=='!'){fputs("live",stdout);fflush(stdout);for(;;){}} if(c=='#')return 7; fwrite(&c,1,1,stdout);} return 0; }
"#;
fn worker_file(p: &Path) {
chown(p, Some(1000), Some(1000)).unwrap();
fs::set_permissions(p, fs::Permissions::from_mode(0o600)).unwrap();
}
fn id(n: u32) -> String {
format!("aaaaaaaa-aaaa-4aaa-8aaa-{n:012}")
}
fn blob(dir: &Path, data: &[u8]) -> serde_json::Value {
let hash = format!("{:x}", Sha256::digest(data));
let path = dir.join(&hash);
if !path.exists() {
fs::write(&path, data).unwrap();
worker_file(&path);
}
serde_json::json!({"digest":hash,"bytes":data.len()})
}
struct Fixture {
root: support::Root,
dir: PathBuf,
config: PathBuf,
socket: PathBuf,
factory: Option<Child>,
}
impl Fixture {
fn new() -> Self {
support::require_vm();
let mut root = support::Root::new();
root.config.max_concurrent_runs = 1;
root.config.identities.count = 1;
root.config.aggregate.task_cpu_quota_us = 100000;
let dir = PathBuf::from(format!(
"/kf/pipeline-{}-{}",
std::process::id(),
COUNT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o711)).unwrap();
let config = dir.join("runtime.json");
fs::write(&config, serde_json::to_vec(&root.config).unwrap()).unwrap();
fs::set_permissions(&config, fs::Permissions::from_mode(0o600)).unwrap();
for n in ["artifacts", "staging"] {
let p = dir.join(n);
fs::create_dir(&p).unwrap();
chown(&p, Some(1000), Some(1000)).unwrap();
fs::set_permissions(&p, fs::Permissions::from_mode(0o700)).unwrap();
}
let socket = PathBuf::from("/var/lib/kf-judge/factory/control.sock");
Self {
root,
dir,
config,
socket,
factory: None,
}
}
fn start(&mut self) {
use std::os::unix::fs::MetadataExt;
let previous = fs::symlink_metadata(&self.socket).ok().map(|m| m.ino());
let mut child = Command::new(std::env::var_os("KF_SANDBOX_PRODUCTION_BIN").unwrap())
.env_clear()
.args(["factory", "--config"])
.arg(&self.config)
.args(["--lifetime-seconds", "180"])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(15);
loop {
assert!(
child.try_wait().unwrap().is_none(),
"factory exited before ready"
);
if self.socket.exists()
&& fs::metadata(&self.socket).unwrap().permissions().mode() & 0o777 == 0o660
&& previous != Some(fs::metadata(&self.socket).unwrap().ino())
{
break;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(5));
}
let own = fs::read_to_string(format!("/proc/{}/cgroup", child.id())).unwrap();
let group = PathBuf::from(format!(
"/sys/fs/cgroup{}",
own.trim().strip_prefix("0::").unwrap()
));
let values: serde_json::Value = ["memory.max", "memory.swap.max", "pids.max", "cpu.max"]
.iter()
.map(|n| {
(
n.to_string(),
serde_json::Value::String(
fs::read_to_string(group.join(n)).unwrap().trim().into(),
),
)
})
.collect::<serde_json::Map<_, _>>()
.into();
assert_eq!(
values,
serde_json::json!({"memory.max":"268435456","memory.swap.max":"0","pids.max":"32","cpu.max":"50000 100000"})
);
println!(
"KF_FACTORY_BUDGET_PROOF {}",
serde_json::json!({"pid":child.id(),"actual_factory_limits":values,"slots":1,"lifetime_seconds":180})
);
let deadline = Instant::now() + Duration::from_secs(5);
while fs::read_to_string(self.root.path.join("memory.max"))
.unwrap()
.trim()
!= self.root.config.aggregate.memory_bytes.to_string()
{
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(5));
}
let total: serde_json::Value = [
"memory.max",
"memory.swap.max",
"pids.max",
"cpu.max",
"cgroup.max.depth",
"cgroup.max.descendants",
]
.iter()
.map(|n| {
(
n.to_string(),
serde_json::Value::String(
fs::read_to_string(self.root.path.join(n))
.unwrap()
.trim()
.into(),
),
)
})
.collect::<serde_json::Map<_, _>>()
.into();
assert_eq!(
total,
serde_json::json!({"memory.max":self.root.config.aggregate.memory_bytes.to_string(),"memory.swap.max":"0","pids.max":self.root.config.aggregate.processes.to_string(),"cpu.max":format!("{} {}",self.root.config.aggregate.cpu_quota_us,self.root.config.aggregate.cpu_period_us),"cgroup.max.depth":"1","cgroup.max.descendants":"1"})
);
println!("KF_FACTORY_TOTAL_PROOF {}", total);
self.factory = Some(child);
}
fn job(&self, language: &str, source: &[u8], inputs: &[&[u8]], answers: &[&[u8]]) -> PathBuf {
let n = COUNT.fetch_add(1, Ordering::Relaxed) + 100;
let mut m: serde_json::Value = serde_json::from_str(include_str!(
"../../../../tests/fixtures/contracts/judge-manifest.valid.json"
))
.unwrap();
let a = self.dir.join("artifacts");
m["language"] = language.into();
m["source"] = blob(&a, source);
m["cases"]=serde_json::Value::Array(inputs.iter().zip(answers).enumerate().map(|(i,(input,expected))|serde_json::json!({"case_id":id(1000+i as u32),"input":blob(&a,input),"expected":blob(&a,expected)})).collect());
m["job_wall_time_us"] = 300_000_000u64.into();
let parsed: kf_contracts::judging::JobManifest = serde_json::from_value(m.clone()).unwrap();
let hash = format!(
"{:x}",
Sha256::digest(kf_contracts::judging::manifest_bytes(&parsed).unwrap())
);
let path = self.dir.join(format!("job-{n}.json"));
fs::write(&path,serde_json::to_vec(&serde_json::json!({"manifest":m,"manifest_digest":hash,"binding":{"evaluation_id":parsed.evaluation_id,"attempt_id":id(n),"worker_id":id(999),"fencing_version":1}})).unwrap()).unwrap();
worker_file(&path);
path
}
fn worker(&self, job: &Path) -> Child {
Command::new(std::env::var_os("KF_WORKER_TEST_BIN").unwrap())
.env_clear()
.args(["judge-submission", "--socket"])
.arg(&self.socket)
.arg("--job")
.arg(job)
.arg("--artifacts")
.arg(self.dir.join("artifacts"))
.arg("--staging")
.arg(self.dir.join("staging"))
.uid(1000)
.gid(1000)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap()
}
fn wait_empty(&self) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let empty = ["tasks", "identities", "captures"].iter().all(|n| {
let p = PathBuf::from(format!("/var/lib/kf-judge/{n}"));
!p.exists() || fs::read_dir(p).unwrap().count() == 0
});
let groups = fs::read_dir(&self.root.path)
.unwrap()
.all(|e| !e.unwrap().file_type().unwrap().is_dir());
if empty && groups {
break;
}
assert!(
Instant::now() < deadline,
"owned task resources did not clear"
);
std::thread::sleep(Duration::from_millis(5));
}
}
fn run(&self, job: &Path, verdict: &str) -> serde_json::Value {
let output = self.worker(job).wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["result"]["report"]["verdict"], verdict, "{value}");
assert_eq!(value["result"]["report"]["cleanup_complete"], true);
self.wait_empty();
assert_eq!(fs::read_dir(self.dir.join("staging")).unwrap().count(), 0);
println!(
"KF_PIPELINE_PROOF {}",
serde_json::json!({"worker_uid":1000,"worker_gid":1000,"value":value})
);
value
}
fn live(&self) {
let path = PathBuf::from("/var/lib/kf-judge/identities/uid-100000/owner.json");
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(text) = fs::read_to_string(&path)
&& text.contains("r-")
{
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
let pid = parsed["owner"]["pid"].as_u64().unwrap();
let prefix = format!(
"/var/lib/kf-judge/captures/kf-{}",
parsed["run_id"].as_str().unwrap()
);
for e in fs::read_dir(format!("/proc/{pid}/fd")).unwrap().flatten() {
if fs::read_link(e.path())
.is_ok_and(|p| p.to_string_lossy().starts_with(&prefix))
&& fs::metadata(e.path()).is_ok_and(|m| m.is_file() && m.len() >= 4)
{
let mut bytes = [0; 4];
if fs::File::open(e.path())
.unwrap()
.read_exact(&mut bytes)
.is_ok()
&& bytes == *b"live"
{
let status = fs::read_to_string(format!("/proc/{pid}/status")).unwrap();
assert!(status.contains("Uid:\t0\t0\t0\t0"));
assert!(status.contains("Threads:\t1"));
assert!(fs::read(format!("/proc/{pid}/environ")).unwrap().is_empty());
println!(
"KF_FACTORY_LIVE_PROOF {}",
serde_json::json!({"pid":pid,"uid":0,"threads":1,"environment_empty":true,"stdout":"live","run_id":parsed["run_id"]})
);
return;
}
}
}
}
assert!(Instant::now() < deadline, "runtime task not reached");
std::thread::sleep(Duration::from_millis(5));
}
}
fn probe(&self, mode: &str, run_id: &str) -> serde_json::Value {
let o = Command::new(std::env::current_exe().unwrap())
.env_clear()
.args([
"--ignored",
"--exact",
"ordinary_control_probe",
"--test-threads=1",
"--nocapture",
])
.env("KF_FACTORY_PROBE", mode)
.env("KF_FACTORY_SOCKET", &self.socket)
.env("KF_FACTORY_INPUTS", self.dir.join("control-inputs"))
.env("KF_FACTORY_RUN", run_id)
.uid(1000)
.gid(1000)
.output()
.unwrap();
assert!(
o.status.success(),
"{} {}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
let text = String::from_utf8(o.stdout).unwrap();
let value: serde_json::Value = serde_json::from_str(
text.lines()
.find(|l| l.contains("KF_FACTORY_CONTROL_PROOF "))
.unwrap()
.split_once("KF_FACTORY_CONTROL_PROOF ")
.unwrap()
.1,
)
.unwrap();
println!("KF_FACTORY_CONTROL_PROOF {value}");
value
}
fn control_inputs(&self) {
let p = self.dir.join("control-inputs");
fs::create_dir(&p).unwrap();
chown(&p, Some(1000), Some(1000)).unwrap();
fs::set_permissions(&p, fs::Permissions::from_mode(0o700)).unwrap();
fs::copy("/kf/bin/worker-native", p.join("program")).unwrap();
fs::write(p.join("stdin"), b"!").unwrap();
for n in ["program", "stdin"] {
worker_file(&p.join(n));
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
if let Some(mut child) = self.factory.take() {
let _ = child.kill();
let _ = child.wait();
}
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
#[ignore = "independent capped VM; ordinary authenticated probe child only"]
fn ordinary_control_probe() {
let Ok(mode) = std::env::var("KF_FACTORY_PROBE") else {
return;
};
let socket = PathBuf::from(std::env::var_os("KF_FACTORY_SOCKET").unwrap());
let run_id = std::env::var("KF_FACTORY_RUN").unwrap();
let publication =
ArtifactRoot::open(Path::new(&std::env::var_os("KF_FACTORY_INPUTS").unwrap())).unwrap();
let mut spec: ExecutionSpec =
serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap();
spec.run_id = run_id.clone();
spec.inputs[0].artifact_id = "program".into();
spec.stdin_artifact = Some("stdin".into());
let client = FactoryClient::new(socket.clone());
match mode.as_str() {
"cancel" => {
let cancelled = AtomicBool::new(false);
let (result, pid) = std::thread::scope(|s| {
s.spawn(|| {
std::thread::sleep(Duration::from_millis(800));
cancelled.store(true, Ordering::Release)
});
client
.execute_controlled(spec.clone(), &publication, None, Some(&cancelled))
.unwrap()
});
assert_eq!(result.report.cleanup, CleanupState::Complete);
assert_eq!(
result
.report
.classify(&spec.validate(&Default::default()).unwrap())
.unwrap(),
ExecutionOutcome::Cancelled
);
println!(
"KF_FACTORY_CONTROL_PROOF {}",
serde_json::json!({"kind":"cancel","run_id":run_id,"pid":pid,"report":result.report})
);
}
"replay" => {
assert!(client.execute(spec, &publication).is_err());
println!(
"KF_FACTORY_CONTROL_PROOF {}",
serde_json::json!({"kind":"replay","run_id":run_id,"refused":true})
);
}
"capacity" => {
let mut first = UnixStream::connect(&socket).unwrap();
raw_send(&mut first, &serde_json::json!({"run_id":run_id}));
let ready = raw_receive(&mut first);
assert_eq!(ready["event"], "ready");
let mut second = UnixStream::connect(&socket).unwrap();
raw_send(&mut second, &serde_json::json!({"run_id":"capacity-extra"}));
assert_eq!(raw_receive(&mut second)["event"], "refused");
drop(first);
drop(second);
println!(
"KF_FACTORY_CONTROL_PROOF {}",
serde_json::json!({"kind":"capacity","slot_limit":1,"extra_refused":true,"queued_disconnect":true})
);
}
"extra" => {
use rustix::fd::AsFd;
use rustix::net::{SendAncillaryBuffer, SendAncillaryMessage, SendFlags};
let mut stream = UnixStream::connect(&socket).unwrap();
raw_send(&mut stream, &serde_json::json!({"run_id":run_id}));
assert_eq!(raw_receive(&mut stream)["event"], "ready");
raw_send(&mut stream, &serde_json::json!({"spec":spec}));
let mut space = [std::mem::MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
let borrowed = [publication.as_fd()];
let mut ancillary = SendAncillaryBuffer::new(&mut space);
assert!(ancillary.push(SendAncillaryMessage::ScmRights(&borrowed)));
assert_eq!(
rustix::net::sendmsg(
&stream,
&[std::io::IoSlice::new(&[0xa5])],
&mut ancillary,
SendFlags::NOSIGNAL
)
.unwrap(),
1
);
raw_send(
&mut stream,
&serde_json::json!({"run_id":"unrequested-frame"}),
);
let reply = raw_receive(&mut stream);
assert_eq!(reply["event"], "completed");
assert!(!reply["report"]["errors"].as_array().unwrap().is_empty());
assert_eq!(reply["report"]["stdout"]["retained_bytes"], 0);
assert_eq!(reply["report"]["cleanup"], "complete");
println!(
"KF_FACTORY_CONTROL_PROOF {}",
serde_json::json!({"kind":"extra_frame","refused_before_user_exec":true,"report":reply["report"]})
);
}
"binding" => {
let mut stream = UnixStream::connect(&socket).unwrap();
raw_send(&mut stream, &serde_json::json!({"run_id":run_id}));
assert_eq!(raw_receive(&mut stream)["event"], "ready");
spec.run_id = "wrong-bound-run".into();
raw_send(&mut stream, &serde_json::json!({"spec":spec}));
let mut buf = [0];
assert_eq!(stream.read(&mut buf).unwrap_or(0), 0);
println!(
"KF_FACTORY_CONTROL_PROOF {}",
serde_json::json!({"kind":"binding","wrong_run_refused":true})
);
}
_ => panic!("unknown fixed probe"),
}
}
fn raw_send(s: &mut UnixStream, v: &serde_json::Value) {
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let b = serde_json::to_vec(v).unwrap();
s.write_all(&[b'K', b'F', 1, 0]).unwrap();
s.write_all(&(b.len() as u32).to_be_bytes()).unwrap();
s.write_all(&b).unwrap();
}
fn raw_receive(s: &mut UnixStream) -> serde_json::Value {
let mut head = [0; 8];
s.read_exact(&mut head).unwrap();
let n = u32::from_be_bytes(head[4..].try_into().unwrap()) as usize;
assert!(n <= 1024);
let mut b = vec![0; n];
s.read_exact(&mut b).unwrap();
serde_json::from_slice(&b).unwrap()
}
#[test]
#[ignore = "independent capped VM: real compile-once C17/CPP20, 3 isolated points and structured CE/WA/RE"]
fn compilation_and_three_points() {
let mut f = Fixture::new();
f.start();
for language in ["c17", "cpp20"] {
let j = f.job(
language,
SOURCE,
&[b"", &[0, 255, 10], b"abc\n"],
&[b"", &[0, 255, 10], b"abc\n"],
);
let v = f.run(&j, "accepted");
let stages = v["stages"].as_array().unwrap();
assert_eq!(stages.len(), 4);
let mut pids = std::collections::HashSet::new();
for s in stages {
assert!(pids.insert(s["helper_pid"].as_u64().unwrap()));
assert_eq!(s["report"]["cleanup"], "complete");
}
assert_eq!(v["result"]["report"]["cases"].as_array().unwrap().len(), 3);
assert_eq!(v["result"]["report"]["compilation"]["status"], "succeeded");
}
for (source, input, answer, verdict) in [
(
b"invalid C source".as_slice(),
b"".as_slice(),
b"".as_slice(),
"compilation_error",
),
(SOURCE, b"binary\n", b"binary", "wrong_answer"),
(SOURCE, b"#", b"", "runtime_error"),
] {
let j = f.job(
"c17",
source,
&[input, input, input],
&[answer, answer, answer],
);
let v = f.run(&j, verdict);
if verdict == "compilation_error" {
assert_eq!(v["stages"].as_array().unwrap().len(), 1);
assert!(
!v["result"]["report"]["compilation"]["diagnostics"]
.as_str()
.unwrap()
.is_empty()
);
assert_eq!(v["result"]["skipped"].as_array().unwrap().len(), 3);
}
}
let j = f.job("c17", SOURCE, &[b"a", b"b", b"c"], &[b"a", b"b", b"c"]);
let mut job: serde_json::Value = serde_json::from_slice(&fs::read(&j).unwrap()).unwrap();
job["manifest"]["compile_limits"]["cpu_time_us"] = 10_000_000.into();
job["manifest"]["compile_limits"]["wall_time_us"] = 1000.into();
let m: kf_contracts::judging::JobManifest =
serde_json::from_value(job["manifest"].clone()).unwrap();
job["manifest_digest"] = format!(
"{:x}",
Sha256::digest(kf_contracts::judging::manifest_bytes(&m).unwrap())
)
.into();
fs::write(&j, serde_json::to_vec(&job).unwrap()).unwrap();
worker_file(&j);
let v = f.run(&j, "compilation_error");
assert_eq!(
v["result"]["report"]["compilation"]["status"],
"compilation_limit"
);
assert_eq!(v["stages"].as_array().unwrap().len(), 1);
}
#[test]
#[ignore = "independent capped VM: actual capacity/replay/binding/cancel and factory crash recovery"]
fn factory_lifecycle_and_refusal() {
let mut f = Fixture::new();
f.start();
f.control_inputs();
for (mode, run) in [
("capacity", "capacity-one"),
("binding", "binding-one"),
("extra", "extra-one"),
("cancel", "cancel-one"),
("replay", "cancel-one"),
] {
f.probe(mode, run);
f.wait_empty();
std::thread::sleep(Duration::from_millis(30));
}
let mut root_client = UnixStream::connect(&f.socket).unwrap();
let body = serde_json::to_vec(&serde_json::json!({"run_id":"root-forged-peer"})).unwrap();
let mut frame = vec![b'K', b'F', 1, 0];
frame.extend((body.len() as u32).to_be_bytes());
frame.extend(body);
root_client
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
if let Err(e) = root_client.write_all(&frame) {
assert!(matches!(
e.kind(),
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset
));
}
let mut byte = [0u8; 1];
match root_client.read(&mut byte) {
Ok(0) => {}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::BrokenPipe
) => {}
other => panic!("unexpected root peer result {other:?}"),
}
drop(root_client);
println!("KF_FACTORY_AUTH_PROOF wrong_uid_valid_request_refused=1");
std::thread::sleep(Duration::from_millis(30));
assert!(f.factory.as_mut().unwrap().try_wait().unwrap().is_none());
let job = f.job("c17", SOURCE, &[b"!", b"a", b"b"], &[b"live", b"a", b"b"]);
let worker = f.worker(&job);
f.live();
let mut old = f.factory.take().unwrap();
old.kill().unwrap();
old.wait().unwrap();
let output = worker.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(v["result"]["report"]["verdict"], "cancelled", "{v}");
assert_eq!(v["result"]["report"]["cleanup_complete"], true);
assert_eq!(v["result"]["report"]["cases"].as_array().unwrap().len(), 1);
assert_eq!(v["result"]["skipped"].as_array().unwrap().len(), 2);
println!("KF_FACTORY_RESTART_PROOF {v}");
f.wait_empty();
f.start();
f.probe("replay", "cancel-one");
let j = f.job("c17", SOURCE, &[b"a", b"b", b"c"], &[b"a", b"b", b"c"]);
f.run(&j, "accepted");
}
#[test]
#[ignore = "independent capped VM: disconnect, initialization failure, retained unknown lease and foreign socket"]
fn faults_preserve_foreign_resources_and_recover_owned() {
let mut f = Fixture::new();
f.start();
// Kernel-authenticated Worker disappears while actual user code is live.
let j = f.job("c17", SOURCE, &[b"!", b"a", b"b"], &[b"live", b"a", b"b"]);
let mut worker = f.worker(&j);
f.live();
worker.kill().unwrap();
worker.wait().unwrap();
f.wait_empty();
println!(
"KF_FACTORY_FAULT_PROOF {}",
serde_json::json!({"kind":"worker_disconnect","owned_resources_zero":true})
);
// Root-owned image publication is invalidated before preparation. No exec.
let work = PathBuf::from(&f.root.config.image_root).join("work");
let moved = work.with_file_name("work-operator-held");
fs::rename(&work, &moved).unwrap();
let j = f.job("c17", SOURCE, &[b"a", b"b", b"c"], &[b"a", b"b", b"c"]);
let o = f.worker(&j).wait_with_output().unwrap();
assert!(o.status.success());
let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
assert_eq!(v["result"]["report"]["verdict"], "system_error");
assert!(
v["result"]["report"]["cases"]
.as_array()
.unwrap()
.is_empty()
);
fs::rename(&moved, &work).unwrap();
f.wait_empty();
println!(
"KF_FACTORY_FAULT_PROOF {}",
serde_json::json!({"kind":"initialization","no_test_points_started":true,"owned_resources_zero":true})
);
// Unknown identity-slot entry prevents UID release/reuse even after tree stop.
let j = f.job("c17", SOURCE, &[b"!", b"a", b"b"], &[b"live", b"a", b"b"]);
let mut worker = f.worker(&j);
f.live();
let slot = PathBuf::from("/var/lib/kf-judge/identities/uid-100000");
let foreign = slot.join("foreign-owner-content");
fs::write(&foreign, b"preserve me").unwrap();
worker.kill().unwrap();
worker.wait().unwrap();
let deadline = Instant::now() + Duration::from_secs(15);
loop {
if let Some(status) = f.factory.as_mut().unwrap().try_wait().unwrap() {
assert!(!status.success());
break;
}
assert!(Instant::now() < deadline, "fault factory did not stop");
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(fs::read(&foreign).unwrap(), b"preserve me");
assert!(slot.exists());
f.factory.take();
println!(
"KF_FACTORY_FAULT_PROOF {}",
serde_json::json!({"kind":"cleanup_failure","foreign_preserved":true,"uid_lease_retained":true,"factory_stopped":true})
);
fs::remove_file(&foreign).unwrap();
f.start();
f.wait_empty();
let j = f.job("c17", SOURCE, &[b"a", b"b", b"c"], &[b"a", b"b", b"c"]);
f.run(&j, "accepted");
// Stale socket recovery is inode-bound; replacement content is never adopted.
let mut child = f.factory.take().unwrap();
child.kill().unwrap();
child.wait().unwrap();
let saved = f.socket.with_file_name("operator-held.sock");
fs::rename(&f.socket, &saved).unwrap();
fs::write(&f.socket, b"foreign socket replacement").unwrap();
let o = Command::new(std::env::var_os("KF_SANDBOX_PRODUCTION_BIN").unwrap())
.env_clear()
.args(["factory", "--config"])
.arg(&f.config)
.args(["--lifetime-seconds", "1"])
.output()
.unwrap();
assert_eq!(o.status.code(), Some(2));
assert_eq!(fs::read(&f.socket).unwrap(), b"foreign socket replacement");
fs::remove_file(&f.socket).unwrap();
fs::rename(saved, &f.socket).unwrap();
f.start();
f.wait_empty();
println!(
"KF_FACTORY_FAULT_PROOF {}",
serde_json::json!({"kind":"socket_replacement","foreign_preserved":true,"owned_restart_recovered":true})
);
}
+1
View File
@@ -12,6 +12,7 @@ kf-engine.workspace = true
kf-sandbox.workspace = true
clap.workspace = true
serde_json.workspace = true
sha2 = "=0.10.9"
[lints]
workspace = true
+3
View File
@@ -111,3 +111,6 @@ pub mod native {
kf_engine::judge(&mut port, &[case]).map_err(|_| io::Error::other("invalid judge request"))
}
}
#[cfg(target_os = "linux")]
pub mod pipeline;
+35
View File
@@ -14,6 +14,16 @@ struct Cli {
}
#[derive(Subcommand)]
enum Command {
JudgeSubmission {
#[arg(long)]
socket: PathBuf,
#[arg(long)]
job: PathBuf,
#[arg(long)]
artifacts: PathBuf,
#[arg(long)]
staging: PathBuf,
},
JudgeOnce {
#[arg(long)]
socket: PathBuf,
@@ -33,6 +43,31 @@ fn main() -> ExitCode {
return ExitCode::SUCCESS;
}
match cli.command {
Some(Command::JudgeSubmission {
socket,
job,
artifacts,
staging,
}) => {
#[cfg(target_os = "linux")]
{
match kf_worker::pipeline::judge_local(&socket, &job, &artifacts, &staging) {
Ok(result) => {
println!("{}", serde_json::json!(result));
ExitCode::SUCCESS
}
Err(_) => {
eprintln!("submission_failed");
ExitCode::from(2)
}
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (socket, job, artifacts, staging);
ExitCode::from(2)
}
}
Some(Command::JudgeOnce {
socket,
job,
+504
View File
@@ -0,0 +1,504 @@
//! Ordinary adapter: fixed GNU command profiles, immutable digest verification,
//! bounded point-at-a-time data and injected Engine ports. No database or sudo.
use kf_contracts::judging::*;
use kf_engine::{ExecutionKind, Observation, submission::*};
use kf_sandbox::{
artifacts::{ArtifactRoot, SnapshotFile, StagePublication},
factory::FactoryClient,
outcome::{ExecutionOutcome, LimitKind, Measurement},
spec::{ExecutionProfile, ExecutionSpec, InputArtifact, ResourceLimits},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use std::{
io::{self, Read},
path::{Path, PathBuf},
sync::atomic::{AtomicBool, Ordering},
time::Instant,
};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Job {
pub manifest: JobManifest,
pub binding: LeaseBinding,
pub manifest_digest: Digest,
}
#[derive(Serialize)]
pub struct StageEvidence {
pub run_id: String,
pub helper_pid: u32,
pub report: kf_sandbox::outcome::ExecutionReport,
}
#[derive(Serialize)]
pub struct Judged {
pub result: SubmissionResult,
pub stages: Vec<StageEvidence>,
}
pub trait ArtifactPort {
fn read(&mut self, reference: &ArtifactReference) -> io::Result<Vec<u8>>;
}
pub struct LocalArtifacts {
root: ArtifactRoot,
}
impl LocalArtifacts {
pub fn open(path: &Path) -> io::Result<Self> {
Ok(Self {
root: ArtifactRoot::open(path)?,
})
}
}
impl ArtifactPort for LocalArtifacts {
fn read(&mut self, r: &ArtifactReference) -> io::Result<Vec<u8>> {
let s = self
.root
.snapshot_artifact(r.digest.as_str(), r.bytes.max(1))?;
if s.bytes() != r.bytes {
return Err(io::Error::other("artifact byte count"));
}
let mut data = Vec::new();
s.reader()?.take(r.bytes + 1).read_to_end(&mut data)?;
verify(r, &data)?;
Ok(data)
}
}
fn digest(data: &[u8]) -> Digest {
format!("{:x}", Sha256::digest(data))
.try_into()
.expect("SHA256 representation")
}
fn verify(r: &ArtifactReference, data: &[u8]) -> io::Result<()> {
if data.len() as u64 != r.bytes || digest(data) != r.digest {
return Err(io::Error::other("artifact digest mismatch"));
}
Ok(())
}
fn stage_error(code: FailureCode, cleanup_complete: bool) -> StageFailure {
StageFailure {
code,
cleanup_complete,
}
}
fn limits(r: &RunLimits) -> ResourceLimits {
ResourceLimits {
cpu_time_us: r.cpu_time_us,
wall_time_us: r.wall_time_us,
memory_bytes: r.memory_bytes,
stack_bytes: r.stack_bytes,
processes: r.processes,
open_files: r.open_files,
stdout_bytes: r.stdout_bytes,
stderr_bytes: r.stderr_bytes,
file_bytes: r.file_bytes,
workdir_bytes: r.workdir_bytes,
}
}
fn metric(m: &Measurement<u64>) -> Option<u64> {
match m {
Measurement::Measured { value } => Some(*value),
Measurement::Unavailable { .. } => None,
}
}
fn kind(o: ExecutionOutcome) -> ExecutionKind {
match o {
ExecutionOutcome::Exited { code } => ExecutionKind::Exited { code },
ExecutionOutcome::Signaled { .. } => ExecutionKind::Signaled,
ExecutionOutcome::LimitExceeded {
primary: LimitKind::Memory,
} => ExecutionKind::MemoryLimit,
ExecutionOutcome::LimitExceeded {
primary: LimitKind::CpuTime | LimitKind::WallTime,
} => ExecutionKind::TimeLimit,
ExecutionOutcome::LimitExceeded {
primary: LimitKind::Stdout | LimitKind::Stderr,
} => ExecutionKind::OutputLimit,
ExecutionOutcome::LimitExceeded { .. } => ExecutionKind::OtherLimit,
ExecutionOutcome::Cancelled => ExecutionKind::Cancelled,
ExecutionOutcome::InfrastructureFailure | ExecutionOutcome::NotStarted => {
ExecutionKind::InfrastructureFailure
}
}
}
pub fn compiler_spec(
language: Language,
source: String,
run_id: String,
budget: &RunLimits,
) -> ExecutionSpec {
let (profile, compiler, standard, name) = match language {
Language::C17 => (
ExecutionProfile::CompileC,
"/usr/bin/gcc",
"-std=c17",
"main.c",
),
Language::Cpp20 => (
ExecutionProfile::CompileCpp,
"/usr/bin/g++",
"-std=c++20",
"main.cpp",
),
};
ExecutionSpec {
schema_version: kf_contracts::SchemaVersion,
run_id,
profile,
argv: vec![
compiler.into(),
standard.into(),
"-O2".into(),
"-pipe".into(),
format!("/work/{name}"),
"-o".into(),
"/work/main".into(),
],
env: Default::default(),
cwd: ".".into(),
inputs: vec![InputArtifact {
artifact_id: source,
path: name.into(),
}],
stdin_artifact: None,
outputs: vec!["main".into()],
limits: limits(budget),
}
}
pub struct Attempt<'a, A: ArtifactPort> {
pub artifacts: A,
pub stages: Vec<StageEvidence>,
pub cancellation: &'a AtomicBool,
factory: FactoryClient,
staging: PathBuf,
started: Instant,
deadline: Instant,
program: Option<(ArtifactReference, SnapshotFile)>,
}
impl<'a, A: ArtifactPort> Attempt<'a, A> {
pub fn new(
artifacts: A,
factory_socket: PathBuf,
staging: PathBuf,
wall_us: u64,
cancellation: &'a AtomicBool,
) -> io::Result<Self> {
if wall_us == 0 || wall_us > MAX_JOB_WALL_US {
return Err(io::Error::other("job deadline"));
}
StagePublication::recover(&staging)?;
let started = Instant::now();
let deadline = started
.checked_add(std::time::Duration::from_micros(wall_us))
.ok_or_else(|| io::Error::other("deadline overflow"))?;
Ok(Self {
artifacts,
stages: vec![],
cancellation,
factory: FactoryClient::new(factory_socket),
staging,
started,
deadline,
program: None,
})
}
fn execute(
&mut self,
spec: ExecutionSpec,
publication: &mut StagePublication,
) -> Result<kf_sandbox::rpc::Executed, StageFailure> {
let id = spec.run_id.clone();
let validated = spec
.clone()
.validate(&kf_sandbox::spec::SpecPolicy::default())
.map_err(|_| stage_error(FailureCode::SandboxUnavailable, true))?;
let result = self.factory.execute_controlled(
spec,
publication.root(),
Some(self.deadline),
Some(self.cancellation),
);
let cleanup = publication.cleanup().is_ok();
let (result, pid) =
result.map_err(|_| stage_error(FailureCode::SandboxUnavailable, false))?;
if !cleanup {
return Err(stage_error(FailureCode::CleanupFailed, false));
}
result
.report
.classify(&validated)
.map_err(|_| stage_error(FailureCode::SandboxUnavailable, false))?;
self.stages.push(StageEvidence {
run_id: id,
helper_pid: pid,
report: result.report.clone(),
});
Ok(result)
}
}
impl<A: ArtifactPort> CompilationPort for Attempt<'_, A> {
fn compile(&mut self, m: &JobManifest, run_id: &str) -> Result<Compiled, StageFailure> {
if m.toolchain.image_revision != 1 || m.toolchain.toolchain_id.as_str() != "gnu-cxx-v1" {
return Err(stage_error(FailureCode::SandboxUnavailable, true));
}
let source = self
.artifacts
.read(&m.source)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
verify(&m.source, &source)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
let mut publication = StagePublication::create(&self.staging, run_id)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
publication
.put("source", &mut source.as_slice(), source.len() as u64)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, false))?;
drop(source);
let spec = compiler_spec(
m.language,
"source".into(),
run_id.into(),
&m.compile_limits,
);
let validated = spec
.clone()
.validate(&kf_sandbox::spec::SpecPolicy::default())
.map_err(|_| stage_error(FailureCode::SandboxUnavailable, true))?;
let result = self.execute(spec, &mut publication)?;
let status = match result
.report
.classify(&validated)
.map_err(|_| stage_error(FailureCode::SandboxUnavailable, false))?
{
ExecutionOutcome::Exited { code: 0 } => CompilationStatus::Succeeded,
ExecutionOutcome::Exited { .. } | ExecutionOutcome::Signaled { .. } => {
CompilationStatus::CompilationError
}
ExecutionOutcome::LimitExceeded { .. } => CompilationStatus::CompilationLimit,
ExecutionOutcome::Cancelled => CompilationStatus::Cancelled,
_ => CompilationStatus::SystemError,
};
let mut raw = Vec::new();
result
.stderr
.reader()
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?
.take(65537)
.read_to_end(&mut raw)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
let mut text = String::from_utf8_lossy(&raw).into_owned();
let mut truncated = result.report.stderr.truncated || text.len() > 65536;
if text.len() > 65536 {
let mut end = 65536;
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
truncated = true;
}
let program = if status == CompilationStatus::Succeeded {
if result.outputs.len() != 1
|| result.outputs[0].path != "main"
|| result.outputs[0].file.bytes() == 0
{
return Err(stage_error(FailureCode::ArtifactUnavailable, true));
}
let output = result.outputs.into_iter().next().expect("one output").file;
let mut h = Sha256::new();
let mut reader = output
.reader()
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
let mut buffer = [0u8; 16384];
loop {
let n = reader
.read(&mut buffer)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
if n == 0 {
break;
}
h.update(&buffer[..n]);
}
let r = ArtifactReference {
digest: format!("{:x}", h.finalize()).try_into().expect("SHA256"),
bytes: output.bytes(),
};
self.program = Some((r.clone(), output));
Some(r)
} else {
None
};
Ok(Compiled {
report: CompilationReport {
status,
program,
diagnostics: text.try_into().expect("bounded diagnostics"),
diagnostics_truncated: truncated,
cpu_time_us: metric(&result.report.usage.cpu_time_us),
memory_peak_bytes: metric(&result.report.usage.memory_peak_bytes),
},
cleanup_complete: true,
failure: (status == CompilationStatus::SystemError)
.then_some(FailureCode::SandboxUnavailable),
})
}
}
impl<A: ArtifactPort> RunPort for Attempt<'_, A> {
fn run(
&mut self,
p: &ArtifactReference,
c: &TestCase,
b: &RunLimits,
run_id: &str,
) -> Result<Observation, StageFailure> {
let input = self
.artifacts
.read(&c.input)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
verify(&c.input, &input)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
let Some((reference, program)) = &self.program else {
return Err(stage_error(FailureCode::ArtifactUnavailable, true));
};
if reference != p {
return Err(stage_error(FailureCode::ArtifactUnavailable, true));
}
let mut publication = StagePublication::create(&self.staging, run_id)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
publication
.put(
"program",
&mut program
.reader()
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?,
p.bytes,
)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, false))?;
publication
.put("stdin", &mut input.as_slice(), input.len() as u64)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, false))?;
drop(input);
let spec = ExecutionSpec {
schema_version: kf_contracts::SchemaVersion,
run_id: run_id.into(),
profile: ExecutionProfile::Native,
argv: vec!["/work/main".into()],
env: Default::default(),
cwd: ".".into(),
inputs: vec![InputArtifact {
artifact_id: "program".into(),
path: "main".into(),
}],
stdin_artifact: Some("stdin".into()),
outputs: vec![],
limits: limits(b),
};
let validated = spec
.clone()
.validate(&kf_sandbox::spec::SpecPolicy::default())
.map_err(|_| stage_error(FailureCode::SandboxUnavailable, true))?;
let result = self.execute(spec, &mut publication)?;
let outcome = result
.report
.classify(&validated)
.map_err(|_| stage_error(FailureCode::SandboxUnavailable, false))?;
let mut stdout = Vec::new();
result
.stdout
.reader()
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?
.take(b.stdout_bytes + 1)
.read_to_end(&mut stdout)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
if stdout.len() as u64 > b.stdout_bytes {
return Err(stage_error(FailureCode::ArtifactUnavailable, true));
}
Ok(Observation {
kind: kind(outcome),
stdout,
cpu_time_us: metric(&result.report.usage.cpu_time_us),
memory_peak_bytes: metric(&result.report.usage.memory_peak_bytes),
})
}
}
impl<A: ArtifactPort> ExpectedPort for Attempt<'_, A> {
fn expected(&mut self, c: &TestCase) -> Result<Vec<u8>, StageFailure> {
let data = self
.artifacts
.read(&c.expected)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
verify(&c.expected, &data)
.map_err(|_| stage_error(FailureCode::ArtifactUnavailable, true))?;
Ok(data)
}
}
impl<A: ArtifactPort> ControlPort for Attempt<'_, A> {
fn elapsed_us(&self) -> u64 {
self.started
.elapsed()
.as_micros()
.try_into()
.unwrap_or(u64::MAX)
}
fn cancelled(&self) -> bool {
self.cancellation.load(Ordering::Acquire)
}
fn stage(&mut self, _: Stage, _: Option<u16>) -> Result<(), StageFailure> {
Ok(())
}
}
pub fn judge_local(
socket: &Path,
job: &Path,
artifacts: &Path,
staging: &Path,
) -> io::Result<Judged> {
let job: Job = serde_json::from_slice(&kf_sandbox::execution::read_bounded_file(job, 262144)?)
.map_err(|_| io::Error::other("invalid submission"))?;
let canonical =
manifest_bytes(&job.manifest).map_err(|_| io::Error::other("invalid manifest"))?;
if digest(&canonical) != job.manifest_digest {
return Err(io::Error::other("manifest digest mismatch"));
}
let cancel = AtomicBool::new(false);
let mut adapter = Attempt::new(
LocalArtifacts::open(artifacts)?,
socket.into(),
staging.into(),
job.manifest.job_wall_time_us,
&cancel,
)?;
let result = judge_submission(
&mut adapter,
&job.manifest,
job.binding,
job.manifest_digest,
)
.map_err(io::Error::other)?;
Ok(Judged {
result,
stages: adapter.stages,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_commands_and_source_digest() {
let m: JobManifest = serde_json::from_str(include_str!(
"../../../../tests/fixtures/contracts/judge-manifest.valid.json"
))
.unwrap();
for (lang, tool, std) in [
(Language::C17, "/usr/bin/gcc", "-std=c17"),
(Language::Cpp20, "/usr/bin/g++", "-std=c++20"),
] {
let s = compiler_spec(lang, "source".into(), "compile".into(), &m.compile_limits);
assert_eq!(s.argv[0], tool);
assert_eq!(s.argv[1], std);
assert_eq!(s.outputs, ["main"]);
assert!(s.validate(&kf_sandbox::spec::SpecPolicy::default()).is_ok());
}
let r = ArtifactReference {
digest: digest(b"a"),
bytes: 1,
};
assert!(verify(&r, b"b").is_err());
assert!(verify(&r, b"a").is_ok());
}
}