From 9a2b1c071fb2db3f8dabc11d60f7f2e92061b378 Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Wed, 7 Oct 2026 16:19:05 +0800 Subject: [PATCH] feat(sandbox): snapshot artifacts through bounded file capabilities --- judge/README.md | 20 +- judge/crates/sandbox/src/artifacts.rs | 269 +++++++++++ judge/crates/sandbox/src/cgroup.rs | 8 + judge/crates/sandbox/src/launcher/root.rs | 22 +- judge/crates/sandbox/src/lib.rs | 3 + judge/crates/sandbox/src/spec.rs | 2 +- judge/crates/sandbox/tests/artifacts_vm.rs | 430 ++++++++++++++++++ .../tests/fixtures/artifact_mount_probe.c | 27 ++ 8 files changed, 778 insertions(+), 3 deletions(-) create mode 100644 judge/crates/sandbox/src/artifacts.rs create mode 100644 judge/crates/sandbox/tests/artifacts_vm.rs create mode 100644 judge/crates/sandbox/tests/fixtures/artifact_mount_probe.c diff --git a/judge/README.md b/judge/README.md index bc69cc3..0d996bb 100644 --- a/judge/README.md +++ b/judge/README.md @@ -49,9 +49,27 @@ cargo run --locked -p kf-sandbox -- validate --spec crates/sandbox/tests/fixture 缺失或为零的限额不会变成无限制。CPU 总时间和墙钟时间分别校验,不假设多线程 CPU 时间小于墙钟。 环境变量仅接受 `LANG`/`LC_ALL` 的 `C`、`C.UTF-8` 和 `TZ=UTC`。 -这些检查尚未验证工件归属、实际文件类型、符号链接、身份与隔离能力;初始化器必须继续完成这些检查, +词法检查不验证工件归属、实际文件类型、符号链接、身份与隔离能力;初始化器必须继续完成这些检查, 保留独立 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` 定义可信监督器的本地结果记录,含退出码/信号、计量、输出工件、启动阶段错误、 diff --git a/judge/crates/sandbox/src/artifacts.rs b/judge/crates/sandbox/src/artifacts.rs new file mode 100644 index 0000000..ea64d54 --- /dev/null +++ b/judge/crates/sandbox/src/artifacts.rs @@ -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 { + 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, + /// Kept separate: stdin data is not implicitly exposed as a /work file. + pub stdin: Option, +} + +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 { + 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 { + 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 { + 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, + 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> { + 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) -> io::Result { + 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 { + // 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, +) -> io::Result { + 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 }) +} diff --git a/judge/crates/sandbox/src/cgroup.rs b/judge/crates/sandbox/src/cgroup.rs index e8e7386..cd146c0 100644 --- a/judge/crates/sandbox/src/cgroup.rs +++ b/judge/crates/sandbox/src/cgroup.rs @@ -274,6 +274,14 @@ impl TaskCgroup { 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. pub fn enroll(&mut self, target: &mut BlockedTarget) -> Result<(), CgroupError> { self.live()?; diff --git a/judge/crates/sandbox/src/launcher/root.rs b/judge/crates/sandbox/src/launcher/root.rs index a1478c2..ae3e07a 100644 --- a/judge/crates/sandbox/src/launcher/root.rs +++ b/judge/crates/sandbox/src/launcher/root.rs @@ -14,6 +14,7 @@ pub struct RootedTarget { // Terminate the private namespace before deleting its outside directory. target: NamespacedTarget, lease: RootLease, + outputs: crate::artifacts::OutputPlan, } fn attach_cleanup(error: &mut InitializationFailure, cleanup: Option) { @@ -81,7 +82,11 @@ impl RootedTarget { attach_cleanup(&mut failure, lease.cleanup().err()); return Err(failure); } - Ok(Self { target, lease }) + Ok(Self { + target, + lease, + outputs: crate::artifacts::OutputPlan::for_spec(spec), + }) } pub fn init_pid(&self) -> u32 { self.target.init_pid() @@ -96,6 +101,21 @@ impl RootedTarget { 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> { + 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<()> { self.target.initialize_proc_dev()?; self.lease.verify_root(self.init_pid())?; diff --git a/judge/crates/sandbox/src/lib.rs b/judge/crates/sandbox/src/lib.rs index 24c0b39..ab53b0e 100644 --- a/judge/crates/sandbox/src/lib.rs +++ b/judge/crates/sandbox/src/lib.rs @@ -9,6 +9,9 @@ pub mod probe; pub mod runtime; pub mod spec; +#[cfg(target_os = "linux")] +pub mod artifacts; + #[cfg(target_os = "linux")] pub mod cgroup; diff --git a/judge/crates/sandbox/src/spec.rs b/judge/crates/sandbox/src/spec.rs index a5629b5..23e09e3 100644 --- a/judge/crates/sandbox/src/spec.rs +++ b/judge/crates/sandbox/src/spec.rs @@ -194,7 +194,7 @@ fn identifier(value: &str, field: &'static str) -> Result<(), SpecError> { 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() || value.len() > MAX_PATH_BYTES || value.split('/').any(|part| { diff --git a/judge/crates/sandbox/tests/artifacts_vm.rs b/judge/crates/sandbox/tests/artifacts_vm.rs new file mode 100644 index 0000000..0c88538 --- /dev/null +++ b/judge/crates/sandbox/tests/artifacts_vm.rs @@ -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 { + 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::(), 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 = (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" + ); +} diff --git a/judge/crates/sandbox/tests/fixtures/artifact_mount_probe.c b/judge/crates/sandbox/tests/fixtures/artifact_mount_probe.c new file mode 100644 index 0000000..2d10cf4 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/artifact_mount_probe.c @@ -0,0 +1,27 @@ +/* Fixed VM-only mount-boundary fixture; no request-controlled paths. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +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; +}