feat(sandbox): snapshot artifacts through bounded file capabilities

This commit is contained in:
2026-10-07 16:19:05 +08:00
parent 2b5e79dd39
commit 9a2b1c071f
8 changed files with 778 additions and 3 deletions
+19 -1
View File
@@ -49,9 +49,27 @@ cargo run --locked -p kf-sandbox -- validate --spec crates/sandbox/tests/fixture
缺失或为零的限额不会变成无限制。CPU 总时间和墙钟时间分别校验,不假设多线程 CPU 时间小于墙钟。 缺失或为零的限额不会变成无限制。CPU 总时间和墙钟时间分别校验,不假设多线程 CPU 时间小于墙钟。
环境变量仅接受 `LANG`/`LC_ALL` 的 `C`、`C.UTF-8` 和 `TZ=UTC`。 环境变量仅接受 `LANG`/`LC_ALL` 的 `C`、`C.UTF-8` 和 `TZ=UTC`。
这些检查尚未验证工件归属、实际文件类型、符号链接、身份与隔离能力;初始化器必须继续完成这些检查, 词法检查不验证工件归属、实际文件类型、符号链接、身份与隔离能力;初始化器必须继续完成这些检查,
保留独立 VM 中的执行、安全和资源验收。 保留独立 VM 中的执行、安全和资源验收。
## 工件文件能力与快照
`artifacts.rs` 接收可信工件提供方选择的目录 FD,按逻辑 ID 打开发布文件;请求不能指定宿主目录。
目录及输入文件检查所有者和权限,文件必须是单链接普通文件。提供方负责工件的业务授权、原子发布,
并在准备期间避免修改发布内容;路径检查不认证业务 ID,复制前后的 inode/时间戳检查不替代内容摘要认证。
使用 `openat2` 的 BENEATH、NO_SYMLINKS、NO_MAGICLINKS、NO_XDEV 原子限定解析;不支持的内核或解析竞态直接拒绝。
先以 O_PATH 检查类型,再经持有 FD 重开该 inode,避免打开 FIFO/设备,也不重新查询用户路径。
输入、独立 stdin 和声明产物复制到 memfd,施加并读回 WRITE/GROW/SHRINK/SEAL 封印,只返回只读 CLOEXEC FD。
复制按 64 KiB 缓冲推进;单文件不超过规格 file_bytes,每批不超过 workdir_bytes 和固定 64 MiB 操作方上限。
数量沿用规格上限:64 个输入加独立 stdin,16 个输出;稀疏文件按逻辑长度计费。批次失败丢弃全部部分快照。
并行准备的总内存预留、工件摘要/发布端口、输入只读挂载、stdin 传递和最终执行后的导出调度仍待接入。
`RootedTarget::snapshot_outputs` 先读回同一任务的冻结门、成员、限额和运行根,再取得其工作目录 FD,
只提取准备时保存的声明路径,不递归枚举用户目录,也不导出符号链接、硬链接、特殊文件或子挂载。
该内部能力已在独立受限 VM 验证文件替换、目录/链接竞态、封印、文件及总量限制、部分 FD 清理。
当前只能对仍受监督器持有的冻结初始化目标取快照;执行后的终止/清空/收集生命周期仍待实现,`run` 继续拒绝。
## 本地执行结果与归因 ## 本地执行结果与归因
`outcome.rs` 定义可信监督器的本地结果记录,含退出码/信号、计量、输出工件、启动阶段错误、 `outcome.rs` 定义可信监督器的本地结果记录,含退出码/信号、计量、输出工件、启动阶段错误、
+269
View File
@@ -0,0 +1,269 @@
//! File capabilities and immutable, bounded snapshots, entirely inside sandbox.
//!
//! Request values are logical IDs/relative paths. A trusted artifact provider
//! supplies an owned directory FD; no request can select a host directory.
//! Outputs are resolved only through a verified, frozen target's work FD.
//! Snapshotting is not publication, input mounting, stdio wiring or an exec permit.
use crate::spec::{ValidatedSpec, path};
use rustix::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
use rustix::fs::{FileType, MemfdFlags, Mode, OFlags, ResolveFlags, SealFlags, Stat};
use std::fs::File;
use std::io::{self, Read, Write};
/// Per-call operator ceiling in addition to the request's file/work limits.
/// The supervisor must also bound concurrent preparation reservations.
pub const MAX_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024;
const SEALS: SealFlags = SealFlags::WRITE
.union(SealFlags::SHRINK)
.union(SealFlags::GROW)
.union(SealFlags::SEAL);
const RESOLVE: ResolveFlags = ResolveFlags::BENEATH
.union(ResolveFlags::NO_SYMLINKS)
.union(ResolveFlags::NO_MAGICLINKS)
.union(ResolveFlags::NO_XDEV);
fn refused(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, message)
}
/// Sealed backing inode and read-only CLOEXEC handle. No writable handle escapes.
pub struct SnapshotFile {
file: File,
bytes: u64,
}
impl SnapshotFile {
pub fn bytes(&self) -> u64 {
self.bytes
}
/// A fresh cursor, retaining the same immutable inode (not a path lookup).
pub fn reader(&self) -> io::Result<File> {
readonly_handle(&self.file)
}
}
impl AsFd for SnapshotFile {
fn as_fd(&self) -> BorrowedFd<'_> {
self.file.as_fd()
}
}
pub struct PreparedInput {
pub path: String,
pub executable: bool,
pub file: SnapshotFile,
}
pub struct PreparedInputs {
pub files: Vec<PreparedInput>,
/// Kept separate: stdin data is not implicitly exposed as a /work file.
pub stdin: Option<SnapshotFile>,
}
pub struct OutputSnapshot {
pub path: String,
pub file: SnapshotFile,
}
/// The provider is responsible for selecting the task's authorized publication
/// directory. Ownership/mode checks do not authenticate artifact business IDs.
pub struct ArtifactRoot {
fd: OwnedFd,
owner: u32,
}
impl ArtifactRoot {
pub fn from_directory(fd: OwnedFd) -> io::Result<Self> {
let stat = rustix::fs::fstat(&fd)?;
let owner = rustix::process::geteuid().as_raw();
if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
|| stat.st_uid != owner
|| stat.st_mode & 0o022 != 0
|| !rustix::io::fcntl_getfd(&fd)?.contains(rustix::io::FdFlags::CLOEXEC)
{
return Err(refused("untrusted artifact directory capability"));
}
Ok(Self { fd, owner })
}
pub fn snapshot_inputs(&self, spec: &ValidatedSpec) -> io::Result<PreparedInputs> {
let directory = rustix::fs::fstat(&self.fd)?;
if directory.st_uid != self.owner || directory.st_mode & 0o022 != 0 {
return Err(refused("artifact directory authority changed"));
}
let request = spec.spec();
let mut budget = SnapshotBudget::for_spec(spec);
let mut files = Vec::with_capacity(request.inputs.len());
for input in &request.inputs {
// Validated IDs are a single component, so no untrusted ancestor
// directory can substitute a differently owned publication tree.
let file = self.snapshot_id(&input.artifact_id, &mut budget)?;
files.push(PreparedInput {
path: input.path.clone(),
executable: request.argv[0] == format!("/work/{}", input.path),
file,
});
}
let stdin = request
.stdin_artifact
.as_ref()
.map(|id| self.snapshot_id(id, &mut budget))
.transpose()?;
Ok(PreparedInputs { files, stdin })
}
fn snapshot_id(&self, id: &str, budget: &mut SnapshotBudget) -> io::Result<SnapshotFile> {
if id.is_empty()
|| id.len() > 64
|| !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
{
return Err(refused("invalid artifact identifier"));
}
snapshot(&self.fd, id, budget, Some(self.owner))
}
}
pub(crate) struct OutputPlan {
paths: Vec<String>,
budget: SnapshotBudget,
}
impl OutputPlan {
pub(crate) fn for_spec(spec: &ValidatedSpec) -> Self {
Self {
paths: spec.spec().outputs.clone(),
budget: SnapshotBudget::for_spec(spec),
}
}
pub(crate) fn collect(&self, work: &OwnedFd) -> io::Result<Vec<OutputSnapshot>> {
if FileType::from_raw_mode(rustix::fs::fstat(work)?.st_mode) != FileType::Directory
|| self.paths.len() > 16
{
return Err(refused("invalid output directory or count"));
}
let mut budget = self.budget;
let mut files = Vec::with_capacity(self.paths.len());
for path in &self.paths {
files.push(OutputSnapshot {
path: path.clone(),
file: snapshot(work, path, &mut budget, None)?,
});
}
// Errors drop every partial FD; there is no partially published batch.
Ok(files)
}
}
#[derive(Clone, Copy)]
struct SnapshotBudget {
file_bytes: u64,
remaining_bytes: u64,
}
impl SnapshotBudget {
fn for_spec(spec: &ValidatedSpec) -> Self {
Self {
file_bytes: spec.spec().limits.file_bytes,
remaining_bytes: spec.spec().limits.workdir_bytes.min(MAX_SNAPSHOT_BYTES),
}
}
}
fn regular(stat: &Stat, owner: Option<u32>) -> io::Result<u64> {
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile
|| stat.st_nlink != 1
|| stat.st_mode & 0o7000 != 0
|| owner.is_some_and(|uid| stat.st_uid != uid || stat.st_mode & 0o022 != 0)
{
return Err(refused("artifact type, links, mode or ownership"));
}
u64::try_from(stat.st_size).map_err(|_| refused("invalid artifact size"))
}
fn unchanged(a: &Stat, b: &Stat) -> bool {
a.st_dev == b.st_dev
&& a.st_ino == b.st_ino
&& a.st_mode == b.st_mode
&& a.st_uid == b.st_uid
&& a.st_gid == b.st_gid
&& a.st_nlink == b.st_nlink
&& a.st_size == b.st_size
&& a.st_mtime == b.st_mtime
&& a.st_mtime_nsec == b.st_mtime_nsec
&& a.st_ctime == b.st_ctime
&& a.st_ctime_nsec == b.st_ctime_nsec
}
fn readonly_handle(fd: impl AsFd) -> io::Result<File> {
// This is the only deliberate magic-link lookup. The numeric FD is owned
// and held throughout; it was already checked as a regular inode. Never
// reopen an untrusted pathname after checking it. NOCTTY/NONBLOCK are
// defense in depth; O_PATH validation occurs before device/FIFO open.
let reopened = rustix::fs::open(
format!("/proc/self/fd/{}", fd.as_fd().as_raw_fd()),
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK | OFlags::NOCTTY,
Mode::empty(),
)?;
let before = rustix::fs::fstat(&fd)?;
let after = rustix::fs::fstat(&reopened)?;
if before.st_dev != after.st_dev || before.st_ino != after.st_ino {
return Err(refused("reopened artifact identity"));
}
Ok(File::from(reopened))
}
fn snapshot(
directory: &OwnedFd,
relative: &str,
budget: &mut SnapshotBudget,
owner: Option<u32>,
) -> io::Result<SnapshotFile> {
path(relative, "artifact").map_err(|_| refused("noncanonical artifact path"))?;
// Atomic containment; reject unsupported openat2 or races, no fallback or
// retry. O_PATH inspects special files without opening their device/pipe.
let fd = rustix::fs::openat2(
directory,
relative,
OFlags::PATH | OFlags::CLOEXEC,
Mode::empty(),
RESOLVE,
)?;
let before = rustix::fs::fstat(&fd)?;
let bytes = regular(&before, owner)?;
if bytes > budget.file_bytes || bytes > budget.remaining_bytes {
return Err(refused("artifact byte budget"));
}
let mut source = readonly_handle(&fd)?;
if !unchanged(&before, &rustix::fs::fstat(&source)?) {
return Err(refused("artifact changed before copy"));
}
let mut target = File::from(rustix::fs::memfd_create(
"kf-artifact",
MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING,
)?);
let mut remaining = bytes;
let mut buffer = [0_u8; 64 * 1024];
while remaining != 0 {
let length = usize::try_from(remaining.min(buffer.len() as u64))
.map_err(|_| refused("artifact copy length"))?;
source.read_exact(&mut buffer[..length])?;
target.write_all(&buffer[..length])?;
remaining -= length as u64;
}
if source.read(&mut buffer[..1])? != 0 || !unchanged(&before, &rustix::fs::fstat(&source)?) {
return Err(refused("artifact changed during copy"));
}
rustix::fs::fchmod(&target, Mode::from_raw_mode(0o400))?;
rustix::fs::fcntl_add_seals(&target, SEALS)?;
if !rustix::fs::fcntl_get_seals(&target)?.contains(SEALS) {
return Err(refused("artifact seals not applied"));
}
let file = readonly_handle(&target)?;
drop(target);
budget.remaining_bytes -= bytes;
Ok(SnapshotFile { file, bytes })
}
+8
View File
@@ -274,6 +274,14 @@ impl TaskCgroup {
Ok(()) Ok(())
} }
/// Read back the kernel freeze gate before inspecting task-owned files.
pub(crate) fn verify_frozen_members(&self, expected: &[u32]) -> io::Result<()> {
self.live()?;
self.applied_limits()?;
self.wait_event("frozen", "1", Duration::from_secs(1))?;
self.verify_members(expected)
}
/// PID comes from the unreaped direct Child; callers cannot enroll arbitrary PIDs. /// PID comes from the unreaped direct Child; callers cannot enroll arbitrary PIDs.
pub fn enroll(&mut self, target: &mut BlockedTarget) -> Result<(), CgroupError> { pub fn enroll(&mut self, target: &mut BlockedTarget) -> Result<(), CgroupError> {
self.live()?; self.live()?;
+21 -1
View File
@@ -14,6 +14,7 @@ pub struct RootedTarget {
// Terminate the private namespace before deleting its outside directory. // Terminate the private namespace before deleting its outside directory.
target: NamespacedTarget, target: NamespacedTarget,
lease: RootLease, lease: RootLease,
outputs: crate::artifacts::OutputPlan,
} }
fn attach_cleanup(error: &mut InitializationFailure, cleanup: Option<io::Error>) { fn attach_cleanup(error: &mut InitializationFailure, cleanup: Option<io::Error>) {
@@ -81,7 +82,11 @@ impl RootedTarget {
attach_cleanup(&mut failure, lease.cleanup().err()); attach_cleanup(&mut failure, lease.cleanup().err());
return Err(failure); return Err(failure);
} }
Ok(Self { target, lease }) Ok(Self {
target,
lease,
outputs: crate::artifacts::OutputPlan::for_spec(spec),
})
} }
pub fn init_pid(&self) -> u32 { pub fn init_pid(&self) -> u32 {
self.target.init_pid() self.target.init_pid()
@@ -96,6 +101,21 @@ impl RootedTarget {
self.lease.budget self.lease.budget
} }
/// Snapshot only declared outputs through this held target's verified work
/// capability. The task is frozen and its PIDs remain owned/unreaped.
/// Publication and the final post-execution export lifecycle are separate.
pub fn snapshot_outputs(&self) -> io::Result<Vec<crate::artifacts::OutputSnapshot>> {
self.cgroup()
.verify_frozen_members(&[self.bootstrap_pid(), self.init_pid()])?;
self.lease.verify_root(self.init_pid())?;
let work = rustix::fs::open(
format!("/proc/{}/root/work", self.init_pid()),
rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)?;
self.outputs.collect(&work)
}
pub(crate) fn initialize_proc_dev(&mut self) -> io::Result<()> { pub(crate) fn initialize_proc_dev(&mut self) -> io::Result<()> {
self.target.initialize_proc_dev()?; self.target.initialize_proc_dev()?;
self.lease.verify_root(self.init_pid())?; self.lease.verify_root(self.init_pid())?;
+3
View File
@@ -9,6 +9,9 @@ pub mod probe;
pub mod runtime; pub mod runtime;
pub mod spec; pub mod spec;
#[cfg(target_os = "linux")]
pub mod artifacts;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod cgroup; pub mod cgroup;
+1 -1
View File
@@ -194,7 +194,7 @@ fn identifier(value: &str, field: &'static str) -> Result<(), SpecError> {
Ok(()) Ok(())
} }
fn path(value: &str, field: &'static str) -> Result<(), SpecError> { pub(crate) fn path(value: &str, field: &'static str) -> Result<(), SpecError> {
if value.is_empty() if value.is_empty()
|| value.len() > MAX_PATH_BYTES || value.len() > MAX_PATH_BYTES
|| value.split('/').any(|part| { || value.split('/').any(|part| {
+430
View File
@@ -0,0 +1,430 @@
#![cfg(target_os = "linux")]
//! Path substitution, special-file and mutation tests run only in the capped VM.
mod support;
use kf_sandbox::artifacts::{ArtifactRoot, SnapshotFile};
use kf_sandbox::launcher::proc_dev::RestrictedRootTarget;
use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec};
use rustix::fd::{AsFd, AsRawFd};
use rustix::fs::{FileType, Mode, OFlags};
use std::fs;
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use support::{Root, binary, children, require_vm};
fn request(id: &str, outputs: &[&str]) -> ExecutionSpec {
let mut s = support::spec(id, 64 << 20, 8).spec().clone();
s.outputs = outputs.iter().map(|s| (*s).into()).collect();
s
}
fn validated(s: ExecutionSpec) -> ValidatedSpec {
s.validate(&SpecPolicy::default()).unwrap()
}
fn read(file: &SnapshotFile) -> Vec<u8> {
let mut bytes = Vec::new();
file.reader().unwrap().read_to_end(&mut bytes).unwrap();
bytes
}
fn descriptors() -> usize {
fs::read_dir("/proc/self/fd").unwrap().count()
}
struct Publications(PathBuf);
impl Publications {
fn new(id: &str) -> Self {
require_vm();
let path = PathBuf::from(format!("/kf/artifacts-{id}"));
fs::create_dir(&path).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
Self(path)
}
fn capability(&self) -> ArtifactRoot {
ArtifactRoot::from_directory(
rustix::fs::open(
&self.0,
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)
.unwrap(),
)
.unwrap()
}
}
impl Drop for Publications {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn input_and_stdin_snapshots_are_sealed_and_do_not_follow_replacement() {
let publications = Publications::new("immutable");
fs::write(publications.0.join("program-001"), b"\0binary\xff").unwrap();
fs::write(publications.0.join("input-001"), []).unwrap();
let capability = publications.capability();
let before = descriptors();
let prepared = capability
.snapshot_inputs(&validated(request("input-seals", &[])))
.unwrap();
assert_eq!(prepared.files.len(), 1);
assert_eq!(prepared.files[0].path, "main");
assert!(prepared.files[0].executable);
assert_eq!(read(&prepared.files[0].file), b"\0binary\xff");
assert_eq!(prepared.stdin.as_ref().unwrap().bytes(), 0);
assert!(read(prepared.stdin.as_ref().unwrap()).is_empty());
fs::rename(
publications.0.join("program-001"),
publications.0.join("old"),
)
.unwrap();
fs::write(publications.0.join("program-001"), b"replacement").unwrap();
fs::write(publications.0.join("old"), b"overwritten backing source").unwrap();
assert_eq!(read(&prepared.files[0].file), b"\0binary\xff");
let seals = rustix::fs::fcntl_get_seals(&prepared.files[0].file).unwrap();
for required in [
rustix::fs::SealFlags::WRITE,
rustix::fs::SealFlags::GROW,
rustix::fs::SealFlags::SHRINK,
rustix::fs::SealFlags::SEAL,
] {
assert!(seals.contains(required));
}
assert!(
rustix::io::fcntl_getfd(&prepared.files[0].file)
.unwrap()
.contains(rustix::io::FdFlags::CLOEXEC)
);
// Even a privileged test that reopens the retained inode writable cannot
// modify/shrink/grow it. This is a real seal, beyond descriptor permissions.
let fd = rustix::fs::open(
format!(
"/proc/self/fd/{}",
prepared.files[0].file.as_fd().as_raw_fd()
),
OFlags::RDWR | OFlags::CLOEXEC,
Mode::empty(),
)
.unwrap();
assert_eq!(
rustix::io::pwrite(&fd, b"tamper", 0),
Err(rustix::io::Errno::PERM)
);
assert_eq!(rustix::fs::ftruncate(&fd, 0), Err(rustix::io::Errno::PERM));
assert_eq!(
rustix::fs::ftruncate(&fd, 100),
Err(rustix::io::Errno::PERM)
);
assert_eq!(read(&prepared.files[0].file), b"\0binary\xff");
drop(fd);
drop(prepared);
assert_eq!(descriptors(), before);
println!(
"KF_ARTIFACT_INPUT_PROOF binary=1 empty_stdin_separate=1 source_replacement_safe=1 kernel_seals=1 readonly_cloexec=1 no_fd_leaks=1"
);
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn input_publications_reject_links_special_files_modes_and_byte_limits() {
let publications = Publications::new("refusals");
let capability = publications.capability();
let mut s = request("input-refusals", &[]);
s.stdin_artifact = None;
s.limits.file_bytes = 4;
let spec = validated(s);
let candidate = publications.0.join("program-001");
let before = descriptors();
for variant in 0..10 {
match variant {
0 => symlink("/image-outside-sentinel", &candidate).unwrap(),
1 => {
fs::write(publications.0.join("other"), b"ok").unwrap();
fs::hard_link(publications.0.join("other"), &candidate).unwrap();
}
2 => rustix::fs::mknodat(
rustix::fs::CWD,
&candidate,
FileType::Fifo,
Mode::from_raw_mode(0o600),
0,
)
.unwrap(),
3 => rustix::fs::mknodat(
rustix::fs::CWD,
&candidate,
FileType::CharacterDevice,
Mode::from_raw_mode(0o600),
rustix::fs::makedev(1, 3),
)
.unwrap(),
4 => fs::create_dir(&candidate).unwrap(),
5 => {
fs::write(&candidate, b"ok").unwrap();
fs::set_permissions(&candidate, fs::Permissions::from_mode(0o666)).unwrap();
}
6 => {
fs::write(&candidate, b"ok").unwrap();
rustix::fs::chown(
&candidate,
Some(rustix::process::Uid::from_raw(100000)),
None,
)
.unwrap();
}
7 => fs::write(&candidate, b"large").unwrap(),
8 => {
let _listener = std::os::unix::net::UnixListener::bind(&candidate).unwrap();
}
9 => {
fs::write(&candidate, b"ok").unwrap();
fs::set_permissions(&candidate, fs::Permissions::from_mode(0o4755)).unwrap();
}
_ => unreachable!(),
}
assert!(
capability.snapshot_inputs(&spec).is_err(),
"variant {variant}"
);
if variant == 4 {
fs::remove_dir(&candidate).unwrap();
} else {
fs::remove_file(&candidate).unwrap();
}
assert_eq!(descriptors(), before);
}
fs::set_permissions(&publications.0, fs::Permissions::from_mode(0o777)).unwrap();
assert!(capability.snapshot_inputs(&spec).is_err());
let directory = rustix::fs::open(
&publications.0,
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)
.unwrap();
assert!(ArtifactRoot::from_directory(directory).is_err());
fs::set_permissions(&publications.0, fs::Permissions::from_mode(0o700)).unwrap();
fs::write(&candidate, [0_u8; 2048]).unwrap();
let mut s = request("input-total", &[]);
s.stdin_artifact = None;
s.limits.file_bytes = 2048;
s.limits.workdir_bytes = 8192;
for n in 0..4 {
s.inputs.push(kf_sandbox::spec::InputArtifact {
artifact_id: "program-001".into(),
path: format!("copy{n}"),
});
}
assert!(capability.snapshot_inputs(&validated(s)).is_err());
assert_eq!(descriptors(), before);
println!(
"KF_ARTIFACT_REFUSAL_PROOF symlink=1 hardlink=1 fifo=1 device=1 socket=1 setuid=1 directory=1 mode=1 owner=1 oversize=1 total_limit=1 partial_fd_cleanup=1 untrusted_root=1 changed_root_authority=1 no_fd_leaks=1"
);
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn declared_outputs_use_the_frozen_work_root_and_enforce_file_and_batch_limits() {
let root = Root::new();
let before = children();
let mut s = request("artifact-export", &["nested/binary", "empty"]);
s.limits.file_bytes = 32 << 10;
s.limits.workdir_bytes = 64 << 10;
let target =
RestrictedRootTarget::prepare(&root.validated(), &validated(s), &binary()).unwrap();
let work = PathBuf::from(format!("/proc/{}/root/work", target.root().init_pid()));
fs::create_dir(work.join("nested")).unwrap();
fs::write(work.join("nested/binary"), b"\0output\xff").unwrap();
fs::write(work.join("empty"), []).unwrap();
fs::write(work.join("undeclared"), b"not exported").unwrap();
let fds = descriptors();
let outputs = target.root().snapshot_outputs().unwrap();
assert_eq!(outputs.len(), 2);
assert_eq!(outputs[0].path, "nested/binary");
assert_eq!(read(&outputs[0].file), b"\0output\xff");
assert_eq!(outputs[1].file.bytes(), 0);
fs::write(work.join("nested/binary"), b"subsequent change").unwrap();
assert_eq!(read(&outputs[0].file), b"\0output\xff");
drop(outputs);
assert_eq!(descriptors(), fds);
fs::File::create(work.join("nested/binary"))
.unwrap()
.set_len(32769)
.unwrap();
assert!(target.root().snapshot_outputs().is_err());
assert_eq!(descriptors(), fds);
fs::File::create(work.join("nested/binary"))
.unwrap()
.set_len(32768)
.unwrap();
fs::File::create(work.join("empty"))
.unwrap()
.set_len(32768)
.unwrap();
let outputs = target.root().snapshot_outputs().unwrap();
assert_eq!(outputs.iter().map(|o| o.file.bytes()).sum::<u64>(), 65536);
drop(outputs);
assert!(
target
.refuse_remaining_isolation()
.cleanup_failure
.is_none()
);
assert_eq!(children(), before);
assert!(!Path::new("/var/lib/kf-judge/tasks/kf-artifact-export").exists());
let mut s = request("artifact-total", &["one", "two", "three"]);
s.limits.file_bytes = 32768;
s.limits.workdir_bytes = 65536;
let target =
RestrictedRootTarget::prepare(&root.validated(), &validated(s), &binary()).unwrap();
let work = PathBuf::from(format!("/proc/{}/root/work", target.root().init_pid()));
for name in ["one", "two", "three"] {
fs::File::create(work.join(name))
.unwrap()
.set_len(32768)
.unwrap();
}
let fds = descriptors();
assert!(
target.root().snapshot_outputs().is_err(),
"aggregate sparse-file logical bytes"
);
assert_eq!(descriptors(), fds);
assert!(
target
.refuse_remaining_isolation()
.cleanup_failure
.is_none()
);
assert_eq!(children(), before);
println!(
"KF_ARTIFACT_EXPORT_PROOF binary=1 empty=1 declared_only=1 private_work=1 immutable_snapshot=1 file_limit=1 total_limit=1 sparse_bytes_counted=1 partial_fd_cleanup=1 task_cleanup=1"
);
}
#[test]
#[ignore = "requires independent VM with externally verified whole-VM caps"]
fn output_resolution_rejects_links_special_files_submounts_and_path_swaps() {
let root = Root::new();
let target = RestrictedRootTarget::prepare(
&root.validated(),
&validated(request("artifact-paths", &["nested/value"])),
&binary(),
)
.unwrap();
let work = PathBuf::from(format!("/proc/{}/root/work", target.root().init_pid()));
fs::create_dir(work.join("nested")).unwrap();
let candidate = work.join("nested/value");
let before = descriptors();
for variant in 0..6 {
match variant {
0 => symlink("/image-outside-sentinel", &candidate).unwrap(),
1 => {
fs::write(work.join("other"), b"hardlinked").unwrap();
fs::hard_link(work.join("other"), &candidate).unwrap();
}
2 => rustix::fs::mknodat(
rustix::fs::CWD,
&candidate,
FileType::Fifo,
Mode::from_raw_mode(0o600),
0,
)
.unwrap(),
3 => rustix::fs::mknodat(
rustix::fs::CWD,
&candidate,
FileType::CharacterDevice,
Mode::from_raw_mode(0o600),
rustix::fs::makedev(1, 3),
)
.unwrap(),
4 => fs::create_dir(&candidate).unwrap(),
5 => symlink("/proc/self/fd/0", &candidate).unwrap(),
_ => unreachable!(),
}
assert!(
target.root().snapshot_outputs().is_err(),
"variant {variant}"
);
if variant == 4 {
fs::remove_dir(&candidate).unwrap();
} else {
fs::remove_file(&candidate).unwrap();
}
assert_eq!(descriptors(), before);
}
fs::remove_dir(work.join("nested")).unwrap();
symlink("/kf", work.join("nested")).unwrap();
assert!(target.root().snapshot_outputs().is_err());
fs::remove_file(work.join("nested")).unwrap();
fs::create_dir(work.join("nested")).unwrap();
fs::write(&candidate, b"inside-only").unwrap();
// A private mount placed by the trusted VM test is rejected even when its
// file has the same inode type. User mount permissions remain unimplemented.
let probe = std::process::Command::new("/kf/bin/artifact-mount-probe")
.arg(target.root().init_pid().to_string())
.output()
.unwrap();
assert!(probe.status.success(), "{:?}", probe.stderr);
assert!(target.root().snapshot_outputs().is_err());
let probe = std::process::Command::new("/kf/bin/artifact-mount-probe")
.arg(target.root().init_pid().to_string())
.arg("undo")
.output()
.unwrap();
assert!(probe.status.success());
let stop = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&stop);
let directory = work.clone();
let attacker = std::thread::spawn(move || {
while !flag.load(Ordering::Relaxed) {
fs::rename(directory.join("nested"), directory.join("held")).unwrap();
symlink("/kf", directory.join("nested")).unwrap();
fs::remove_file(directory.join("nested")).unwrap();
fs::rename(directory.join("held"), directory.join("nested")).unwrap();
}
});
let mut accepted = 0;
let mut rejected = 0;
for _ in 0..200 {
match target.root().snapshot_outputs() {
Ok(outputs) => {
assert_eq!(read(&outputs[0].file), b"inside-only");
accepted += 1;
}
Err(_) => rejected += 1,
}
}
stop.store(true, Ordering::Relaxed);
attacker.join().unwrap();
assert_eq!(accepted + rejected, 200);
assert_eq!(descriptors(), before);
assert!(
target
.refuse_remaining_isolation()
.cleanup_failure
.is_none()
);
for invalid in [
"../outside",
"/outside",
"nested/../outside",
"nested//value",
"nested/./value",
] {
assert!(
request("artifact-lexical", &[invalid])
.validate(&SpecPolicy::default())
.is_err()
);
}
let too_many: Vec<String> = (0..17).map(|n| format!("file{n}")).collect();
let mut s = request("artifact-count", &[]);
s.outputs = too_many;
assert!(s.validate(&SpecPolicy::default()).is_err());
println!(
"KF_ARTIFACT_PATH_PROOF symlink=1 hardlink=1 fifo=1 device=1 directory=1 magiclink=1 ancestor_symlink=1 submount=1 traversal=1 count=1 rename_race_attempts=200 accepted={accepted} rejected={rejected} outside_content_absent=1 no_fd_leaks=1"
);
}
@@ -0,0 +1,27 @@
/* Fixed VM-only mount-boundary fixture; no request-controlled paths. */
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/mount.h>
#include <unistd.h>
int main(int argc, char **argv) {
if (argc < 2 || argc > 3) return 2;
char marker[64] = {0};
FILE *f = fopen("/kf/INDEPENDENT_VM", "r");
if (!f || !fgets(marker, sizeof marker, f) || strcmp(marker, "isolated-test-guest\n")) return 3;
fclose(f);
char *end = NULL;
long pid = strtol(argv[1], &end, 10);
if (!end || *end || pid <= 1) return 4;
char ns[64], root[64];
snprintf(ns, sizeof ns, "/proc/%ld/ns/mnt", pid);
snprintf(root, sizeof root, "/proc/%ld/root", pid);
int fd = open(ns, O_RDONLY | O_CLOEXEC), dir = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd < 0 || dir < 0 || setns(fd, CLONE_NEWNS) || fchdir(dir) || chroot(".") || chdir("/")) return 5;
close(fd); close(dir);
if (argc == 3) return umount("/work/nested/value") ? 6 : 0;
return mount("/image-sentinel", "/work/nested/value", NULL, MS_BIND, NULL) ? 7 : 0;
}