feat(sandbox): transport bounded stdio and close inherited user descriptors
This commit is contained in:
@@ -416,3 +416,15 @@ pidfd_getfd 仍拒绝,仅增加可见进程的只读 pidfd_open 以支持等
|
||||
主进程退出仍有后代时取消、控制端断开及真实 exec ENOENT。探针使用与生产相同的
|
||||
私有回收/凭据/seccomp 原语,在新 PID namespace 的 PID 1 运行;它不是完整任务
|
||||
执行 API。CLI run 继续拒绝,stdio/限额/计量与完整监督全部验收前不暴露执行放行。
|
||||
|
||||
`io::PipePump` now feeds immutable snapshot stdin and drains independent stdout
|
||||
and stderr pipes in bounded nonblocking ticks. Only blocking child ends reach
|
||||
FDs 0/1/2; anonymous captures stay in a trusted 0700 directory capability, have
|
||||
per-stream/file/aggregate byte ceilings and no published writable descriptor.
|
||||
An over-limit observation retains at most the allowed prefix and requires
|
||||
whole-tree cancellation; its byte count is a lower bound. Early stdin closure
|
||||
uses thread-local SIGPIPE masking, preserving global disposition and prior
|
||||
pending signals. Init is explicitly non-dumpable before filtering so same-UID
|
||||
users cannot reopen its control/capture handles through proc. Fixed guarded VM
|
||||
probes exercise the exact transport/reaper; full CLI exec, accounting and
|
||||
post-run artifact publication still await their remaining gates.
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Fixed guarded VM role using the exact private production reaper/reducer.
|
||||
//! Exact production pipe pump and private reaper, guarded fixed VM-only fixture.
|
||||
mod launcher {
|
||||
pub mod entry {
|
||||
pub use kf_sandbox::launcher::entry::ensure_single_threaded;
|
||||
}
|
||||
}
|
||||
mod io {
|
||||
pub use kf_sandbox::io::ChildPipes;
|
||||
}
|
||||
mod outcome {
|
||||
pub use kf_sandbox::outcome::Termination;
|
||||
}
|
||||
mod spec {
|
||||
pub use kf_sandbox::spec::{ExecutionProfile, ValidatedSpec};
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/isolation/identity.rs"]
|
||||
pub(crate) mod identity;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/isolation/namespaces.rs"]
|
||||
mod namespaces;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/isolation/process_tree.rs"]
|
||||
mod process_tree;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/isolation/seccomp.rs"]
|
||||
pub(crate) mod seccomp;
|
||||
mod isolation {
|
||||
pub(crate) use crate::{identity, seccomp};
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[path = "../tests/support/mod.rs"]
|
||||
mod support;
|
||||
use rustix::fd::{AsRawFd, OwnedFd};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
os::unix::net::UnixStream,
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn init(mode: &str) {
|
||||
assert_eq!(rustix::process::getpid(), rustix::process::Pid::INIT);
|
||||
rustix::process::set_parent_process_death_signal(Some(rustix::process::Signal::KILL)).unwrap();
|
||||
let mut control =
|
||||
UnixStream::from(rustix::io::fcntl_dupfd_cloexec(rustix::stdio::stdin(), 3).unwrap());
|
||||
assert_eq!(control.as_raw_fd(), 3);
|
||||
control
|
||||
.set_write_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
rustix::mount::mount(
|
||||
"proc",
|
||||
"/proc",
|
||||
"proc",
|
||||
rustix::mount::MountFlags::NOSUID
|
||||
| rustix::mount::MountFlags::NODEV
|
||||
| rustix::mount::MountFlags::NOEXEC,
|
||||
Some(c"hidepid=2,subset=pid"),
|
||||
)
|
||||
.unwrap();
|
||||
let path = format!("/kf/io-capture-{}", mode);
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
if mode == "storage-fail" {
|
||||
rustix::mount::mount(
|
||||
"tmpfs",
|
||||
&path,
|
||||
"tmpfs",
|
||||
rustix::mount::MountFlags::NOSUID
|
||||
| rustix::mount::MountFlags::NODEV
|
||||
| rustix::mount::MountFlags::NOEXEC,
|
||||
Some(c"size=8192,mode=0700"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let directory = rustix::fs::open(
|
||||
&path,
|
||||
rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC,
|
||||
rustix::fs::Mode::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
let artifacts = kf_sandbox::artifacts::ArtifactRoot::from_directory(
|
||||
rustix::fs::open(
|
||||
"/kf/io-publications",
|
||||
rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC,
|
||||
rustix::fs::Mode::empty(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut request = support::spec("io-fixture", 64 << 20, 8).spec().clone();
|
||||
request.argv = vec!["/work/main".into(), mode.into()];
|
||||
if mode == "empty" {
|
||||
request.stdin_artifact = None;
|
||||
}
|
||||
if mode == "stdout-limit" {
|
||||
request.limits.stdout_bytes = 4096;
|
||||
}
|
||||
if mode == "stderr-limit" {
|
||||
request.limits.stderr_bytes = 4096;
|
||||
}
|
||||
let spec = request
|
||||
.validate(&kf_sandbox::spec::SpecPolicy::default())
|
||||
.unwrap();
|
||||
let data: Vec<_> = (0..262144).map(|i| (i % 251) as u8).collect();
|
||||
std::fs::write("/kf/io-publications/input-001", &data).unwrap();
|
||||
let input = artifacts.snapshot_inputs(&spec).unwrap().stdin;
|
||||
// Snapshot cursor/content do not follow publication changes.
|
||||
std::fs::write("/kf/io-publications/input-001", b"changed-after-sealing").unwrap();
|
||||
let (mut pump, child) = kf_sandbox::io::PipePump::prepare(input, &spec, directory).unwrap();
|
||||
child.validate_for_exec().unwrap();
|
||||
for fd in [&child.input, &child.output, &child.error] {
|
||||
assert!(
|
||||
!rustix::fs::fcntl_getfl(fd)
|
||||
.unwrap()
|
||||
.contains(rustix::fs::OFlags::NONBLOCK)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&path).unwrap().count(),
|
||||
0,
|
||||
"anonymous capture names leaked"
|
||||
);
|
||||
// The captures hold no directory inode; the private empty directory can be
|
||||
// removed before dropping identity and every anonymous inode dies on exit.
|
||||
if mode != "storage-fail" {
|
||||
std::fs::remove_dir(&path).unwrap();
|
||||
}
|
||||
drop(artifacts);
|
||||
let plan = process_tree::ExecPlan::new(&spec).unwrap();
|
||||
default_sigpipe();
|
||||
identity::reduce_current(100000, 100000).unwrap();
|
||||
seccomp::install(spec::ExecutionProfile::Native).unwrap();
|
||||
let mut reaper = process_tree::Reaper::spawn(plan, child).unwrap();
|
||||
control.write_all(b"READY\n").unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let output = match pump.poll() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
assert_eq!(mode, "storage-fail", "unexpected pump error: {e}");
|
||||
assert_eq!(e.raw_os_error(), Some(libc::ENOSPC));
|
||||
let status = pump.status();
|
||||
assert!(!status.output_limit_exceeded());
|
||||
assert!(status.stdout.retained + status.stderr.retained <= 8192);
|
||||
assert!(
|
||||
status.stdout.observed + status.stderr.observed
|
||||
> status.stdout.retained + status.stderr.retained
|
||||
);
|
||||
pump.close_input();
|
||||
control.write_all(b"PROVED\n").unwrap();
|
||||
reaper.shutdown(process_tree::NamespaceEnd::MonitorFailed);
|
||||
}
|
||||
};
|
||||
let observed = reaper.poll().unwrap();
|
||||
if output.output_limit_exceeded() {
|
||||
let captured = if mode == "stdout-limit" {
|
||||
pump.stdout()
|
||||
} else {
|
||||
assert_eq!(mode, "stderr-limit");
|
||||
pump.stderr()
|
||||
};
|
||||
let status = captured.status();
|
||||
assert!(status.exceeded && !status.eof);
|
||||
assert_eq!(status.retained, 4096);
|
||||
assert_eq!(status.observed, 4097);
|
||||
let mut prefix = [0; 4096];
|
||||
assert_eq!(captured.read_at(&mut prefix, 0).unwrap(), 4096);
|
||||
assert!(prefix.iter().all(|b| *b == 0x5a));
|
||||
// At least one descendant remains; exiting PID1 must kill it even
|
||||
// when main already died of SIGPIPE after its output end closed.
|
||||
assert!(!observed.empty);
|
||||
pump.close_input();
|
||||
control.write_all(b"PROVED\n").unwrap();
|
||||
reaper.shutdown(process_tree::NamespaceEnd::Cancelled);
|
||||
}
|
||||
if observed.empty && output.outputs_eof() {
|
||||
assert_eq!(
|
||||
observed.main,
|
||||
Some(outcome::Termination::Exited { code: 7 })
|
||||
);
|
||||
assert_eq!(
|
||||
observed.exec,
|
||||
process_tree::ExecObservation::NoErrorObserved
|
||||
);
|
||||
assert!(!output.output_limit_exceeded() && output.stdin_closed);
|
||||
if mode == "duplex" {
|
||||
assert_eq!(output.stdin_sent, 262144);
|
||||
assert!(!output.stdin_closed_early);
|
||||
assert_capture(pump.stdout(), 262144, 0);
|
||||
assert_capture(pump.stderr(), 262144, 0xa5);
|
||||
} else if mode == "early-close" {
|
||||
assert!(output.stdin_closed_early && output.stdin_sent < 262144);
|
||||
let mut bytes = [0; 8];
|
||||
assert_eq!(pump.stdout().read_at(&mut bytes, 0).unwrap(), 8);
|
||||
assert_eq!(&bytes, b"CLOSED\0\xff");
|
||||
assert_eq!(output.stderr.observed, 0);
|
||||
} else {
|
||||
assert_eq!(mode, "empty");
|
||||
assert_eq!(output.stdin_sent, 0);
|
||||
assert_eq!(output.stdout.observed, 0);
|
||||
assert_eq!(output.stderr.observed, 0);
|
||||
}
|
||||
control.write_all(b"PROVED\n").unwrap();
|
||||
reaper.shutdown(process_tree::NamespaceEnd::Drained);
|
||||
}
|
||||
assert!(Instant::now() < deadline, "bounded I/O proof stalled");
|
||||
let mut poll = [rustix::event::PollFd::new(
|
||||
&control,
|
||||
rustix::event::PollFlags::IN,
|
||||
)];
|
||||
rustix::event::poll(
|
||||
&mut poll,
|
||||
Some(&rustix::event::Timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 100_000,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
if !poll[0].revents().is_empty() {
|
||||
reaper.shutdown(process_tree::NamespaceEnd::ControlLost);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn assert_capture(capture: &kf_sandbox::io::Capture, length: u64, xor: u8) {
|
||||
assert_eq!(capture.status().retained, length);
|
||||
assert_eq!(capture.status().observed, length);
|
||||
assert!(capture.status().eof);
|
||||
let mut chunk = [0; 4096];
|
||||
let mut offset = 0;
|
||||
while offset < length {
|
||||
let n = capture.read_at(&mut chunk, offset).unwrap();
|
||||
assert!(n > 0);
|
||||
for (i, b) in chunk[..n].iter().enumerate() {
|
||||
assert_eq!(*b, (((offset + i as u64) % 251) as u8) ^ xor);
|
||||
}
|
||||
offset += n as u64;
|
||||
}
|
||||
assert_eq!(capture.read_at(&mut chunk, length).unwrap(), 0);
|
||||
}
|
||||
#[allow(unsafe_code)]
|
||||
fn default_sigpipe() {
|
||||
// SAFETY: fixed, single-threaded, guarded VM init before user fork. This
|
||||
// makes early-close exercise actual SIGPIPE containment, not Rust's IGN.
|
||||
unsafe {
|
||||
assert_ne!(libc::signal(libc::SIGPIPE, libc::SIG_DFL), libc::SIG_ERR);
|
||||
}
|
||||
}
|
||||
fn signal_mask_proof() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
launcher::entry::ensure_single_threaded().unwrap();
|
||||
assert_ne!(rustix::process::getpid(), rustix::process::Pid::INIT);
|
||||
let path = "/kf/io-signal-capture";
|
||||
std::fs::create_dir(path).unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let directory = rustix::fs::open(
|
||||
path,
|
||||
rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC,
|
||||
rustix::fs::Mode::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
let artifacts = kf_sandbox::artifacts::ArtifactRoot::from_directory(
|
||||
rustix::fs::open(
|
||||
"/kf/io-publications",
|
||||
rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC,
|
||||
rustix::fs::Mode::empty(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let spec = support::spec("io-signal", 64 << 20, 8);
|
||||
let input = artifacts.snapshot_inputs(&spec).unwrap().stdin;
|
||||
let bad_directory = rustix::io::fcntl_dupfd_cloexec(&directory, 4).unwrap();
|
||||
let mut bad_spec = spec.spec().clone();
|
||||
bad_spec.limits.file_bytes = 4096;
|
||||
assert!(
|
||||
kf_sandbox::io::PipePump::prepare(
|
||||
None,
|
||||
&bad_spec
|
||||
.validate(&kf_sandbox::spec::SpecPolicy::default())
|
||||
.unwrap(),
|
||||
bad_directory
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let (mut pump, mut child) = kf_sandbox::io::PipePump::prepare(input, &spec, directory).unwrap();
|
||||
child.validate_for_exec().unwrap();
|
||||
let alias = rustix::io::fcntl_dupfd_cloexec(&child.output, 4).unwrap();
|
||||
let original = std::mem::replace(&mut child.error, alias);
|
||||
assert!(child.validate_for_exec().is_err());
|
||||
child.error = original;
|
||||
rustix::fs::fcntl_setfl(&child.output, rustix::fs::OFlags::NONBLOCK).unwrap();
|
||||
assert!(child.validate_for_exec().is_err());
|
||||
rustix::fs::fcntl_setfl(&child.output, rustix::fs::OFlags::empty()).unwrap();
|
||||
std::mem::swap(&mut child.input, &mut child.output);
|
||||
assert!(child.validate_for_exec().is_err());
|
||||
std::mem::swap(&mut child.input, &mut child.output);
|
||||
child.validate_for_exec().unwrap();
|
||||
drop(child);
|
||||
default_sigpipe();
|
||||
let before = std::fs::read_to_string("/proc/self/status").unwrap();
|
||||
let observed = pump.poll().unwrap();
|
||||
assert!(observed.stdin_closed_early && observed.outputs_eof());
|
||||
let after = std::fs::read_to_string("/proc/self/status").unwrap();
|
||||
for field in ["SigBlk:", "SigPnd:", "ShdPnd:"] {
|
||||
assert_eq!(
|
||||
before.lines().find(|s| s.starts_with(field)),
|
||||
after.lines().find(|s| s.starts_with(field))
|
||||
);
|
||||
}
|
||||
drop(pump);
|
||||
assert_eq!(std::fs::read_dir(path).unwrap().count(), 0);
|
||||
std::fs::remove_dir(path).unwrap();
|
||||
println!(
|
||||
"KF_IO_SIGPIPE_PROOF default_disposition=1 ordinary_pid=1 early_close_survived=1 mask_restored=1 no_pending_leak=1 anonymous_cleanup=1 invalid_pipe_alias_direction_flags_and_budget_denied=1"
|
||||
);
|
||||
}
|
||||
fn line(control: &mut UnixStream) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let mut b = [0];
|
||||
control.read_exact(&mut b).unwrap();
|
||||
if b[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
bytes.push(b[0]);
|
||||
assert!(bytes.len() < 32);
|
||||
}
|
||||
String::from_utf8(bytes).unwrap()
|
||||
}
|
||||
fn main() {
|
||||
support::require_vm();
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
assert_eq!(args.len(), 3);
|
||||
assert!(
|
||||
[
|
||||
"duplex",
|
||||
"empty",
|
||||
"early-close",
|
||||
"stdout-limit",
|
||||
"stderr-limit",
|
||||
"storage-fail"
|
||||
]
|
||||
.contains(&args[2].as_str())
|
||||
);
|
||||
if args[1] == "signal-mask" {
|
||||
signal_mask_proof();
|
||||
return;
|
||||
}
|
||||
if args[1] == "init" {
|
||||
init(&args[2]);
|
||||
return;
|
||||
}
|
||||
assert_eq!(args[1], "fixed");
|
||||
launcher::entry::ensure_single_threaded().unwrap();
|
||||
let (mut parent, child) = UnixStream::pair().unwrap();
|
||||
parent
|
||||
.set_read_timeout(Some(Duration::from_secs(12)))
|
||||
.unwrap();
|
||||
parent
|
||||
.set_write_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
let own = std::env::current_exe().unwrap();
|
||||
namespaces::create().unwrap();
|
||||
let mut process = Command::new(own)
|
||||
.args(["init", &args[2]])
|
||||
.env_clear()
|
||||
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
|
||||
.stdin(Stdio::from(OwnedFd::from(child)))
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pidfd = rustix::process::pidfd_open(
|
||||
rustix::process::Pid::from_raw(process.id() as i32).unwrap(),
|
||||
rustix::process::PidfdFlags::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(line(&mut parent), "READY");
|
||||
assert_eq!(line(&mut parent), "PROVED");
|
||||
drop(parent);
|
||||
let deadline = Instant::now() + Duration::from_secs(12);
|
||||
let status = loop {
|
||||
if let Some(s) = process.try_wait().unwrap() {
|
||||
break s;
|
||||
}
|
||||
assert!(Instant::now() < deadline);
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
};
|
||||
if args[2] == "storage-fail" {
|
||||
assert_eq!(status.code(), Some(125));
|
||||
// The fixed outer wrapper shares this private mount namespace with
|
||||
// init; its own lifetime still pins the temporary capture mount.
|
||||
rustix::mount::unmount(
|
||||
"/kf/io-capture-storage-fail",
|
||||
rustix::mount::UnmountFlags::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir("/kf/io-capture-storage-fail").unwrap();
|
||||
} else {
|
||||
assert!(status.success(), "{status}");
|
||||
}
|
||||
let mut poll = [rustix::event::PollFd::new(
|
||||
&pidfd,
|
||||
rustix::event::PollFlags::IN,
|
||||
)];
|
||||
assert_eq!(
|
||||
rustix::event::poll(
|
||||
&mut poll,
|
||||
Some(&rustix::event::Timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0
|
||||
})
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
println!(
|
||||
"KF_IO_PROOF mode={} child_fd012=1 snapshot_input=1 bounded_binary_capture=1 init_pidfd_ready=1 namespace_cleanup=1",
|
||||
args[2]
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ mod launcher {
|
||||
pub use kf_sandbox::launcher::entry::ensure_single_threaded;
|
||||
}
|
||||
}
|
||||
mod io {
|
||||
pub use kf_sandbox::io::ChildPipes;
|
||||
}
|
||||
mod outcome {
|
||||
pub use kf_sandbox::outcome::Termination;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Bounded, nonblocking parent transport. Only pipe ends reach user FDs012.
|
||||
//! Input is an immutable authorized snapshot; capture storage is anonymous in
|
||||
//! an operator-owned private directory. No request pathname or unbounded Vec.
|
||||
//! The supervisor must cancel the whole tree on limit/read/storage failure.
|
||||
use crate::{artifacts::SnapshotFile, spec::ValidatedSpec};
|
||||
use rustix::fd::{AsRawFd, OwnedFd};
|
||||
use rustix::fs::{FileType, Mode, OFlags};
|
||||
use std::io;
|
||||
|
||||
const CHUNK: usize = 4096;
|
||||
fn denied(s: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, s)
|
||||
}
|
||||
|
||||
/// Blocking, owned CLOEXEC ends; the exec primitive duplicates these to 012
|
||||
/// and closes every other descriptor, including control and capture handles.
|
||||
pub struct ChildPipes {
|
||||
pub input: OwnedFd,
|
||||
pub output: OwnedFd,
|
||||
pub error: OwnedFd,
|
||||
}
|
||||
impl ChildPipes {
|
||||
pub fn validate_for_exec(&self) -> io::Result<()> {
|
||||
let mut identities = [(0, 0); 3];
|
||||
for (i, (fd, access)) in [
|
||||
(&self.input, OFlags::RDONLY),
|
||||
(&self.output, OFlags::WRONLY),
|
||||
(&self.error, OFlags::WRONLY),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let stat = rustix::fs::fstat(fd)?;
|
||||
let flags = rustix::fs::fcntl_getfl(fd)?;
|
||||
if fd.as_raw_fd() < 4
|
||||
|| FileType::from_raw_mode(stat.st_mode) != FileType::Fifo
|
||||
|| flags.intersection(OFlags::ACCMODE) != access
|
||||
|| flags.contains(OFlags::NONBLOCK)
|
||||
|| !rustix::io::fcntl_getfd(fd)?.contains(rustix::io::FdFlags::CLOEXEC)
|
||||
{
|
||||
return Err(denied("owned blocking CLOEXEC child pipe end"));
|
||||
}
|
||||
let identity = (stat.st_dev, stat.st_ino);
|
||||
if identities[..i].contains(&identity) {
|
||||
return Err(denied("independent stdio pipes"));
|
||||
}
|
||||
identities[i] = identity;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CaptureStatus {
|
||||
/// A lower bound if the tree is cancelled or collection fails.
|
||||
pub observed: u64,
|
||||
pub retained: u64,
|
||||
pub exceeded: bool,
|
||||
pub eof: bool,
|
||||
}
|
||||
pub struct Capture {
|
||||
fd: OwnedFd,
|
||||
pipe: Option<OwnedFd>,
|
||||
limit: u64,
|
||||
status: CaptureStatus,
|
||||
}
|
||||
impl Capture {
|
||||
pub fn status(&self) -> CaptureStatus {
|
||||
self.status
|
||||
}
|
||||
/// Bounded caller buffer, no writable capture capability escapes.
|
||||
pub fn read_at(&self, buffer: &mut [u8], offset: u64) -> io::Result<usize> {
|
||||
let length = buffer
|
||||
.len()
|
||||
.min(self.status.retained.saturating_sub(offset) as usize);
|
||||
if length == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
Ok(rustix::io::pread(&self.fd, &mut buffer[..length], offset)?)
|
||||
}
|
||||
fn drain(&mut self) -> io::Result<()> {
|
||||
let Some(pipe) = self.pipe.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut buffer = [0; CHUNK];
|
||||
// Observe one byte beyond the ceiling, retain only the allowed prefix.
|
||||
let length = CHUNK.min((self.limit - self.status.retained + 1) as usize);
|
||||
let n = match rustix::io::read(pipe, &mut buffer[..length]) {
|
||||
Ok(0) => {
|
||||
self.pipe = None;
|
||||
self.status.eof = true;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(n) => n,
|
||||
Err(rustix::io::Errno::AGAIN | rustix::io::Errno::INTR) => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
self.status.observed = self
|
||||
.status
|
||||
.observed
|
||||
.checked_add(n as u64)
|
||||
.ok_or_else(|| denied("output counter overflow"))?;
|
||||
let keep = n.min((self.limit - self.status.retained) as usize);
|
||||
let mut done = 0;
|
||||
while done < keep {
|
||||
match rustix::io::pwrite(&self.fd, &buffer[done..keep], self.status.retained) {
|
||||
Ok(0) => return Err(io::Error::new(io::ErrorKind::WriteZero, "capture storage")),
|
||||
Ok(n) => {
|
||||
done += n;
|
||||
self.status.retained += n as u64;
|
||||
}
|
||||
Err(rustix::io::Errno::INTR) => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
if keep < n {
|
||||
self.status.exceeded = true;
|
||||
self.pipe = None; // No further allocation/drain on this stream.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PumpStatus {
|
||||
pub stdin_sent: u64,
|
||||
pub stdin_closed: bool,
|
||||
pub stdin_closed_early: bool,
|
||||
pub stdout: CaptureStatus,
|
||||
pub stderr: CaptureStatus,
|
||||
}
|
||||
impl PumpStatus {
|
||||
pub fn output_limit_exceeded(self) -> bool {
|
||||
self.stdout.exceeded || self.stderr.exceeded
|
||||
}
|
||||
pub fn outputs_eof(self) -> bool {
|
||||
self.stdout.eof && self.stderr.eof
|
||||
}
|
||||
}
|
||||
pub struct PipePump {
|
||||
input: Option<SnapshotFile>,
|
||||
writer: Option<OwnedFd>,
|
||||
pending: [u8; CHUNK],
|
||||
position: usize,
|
||||
length: usize,
|
||||
sent: u64,
|
||||
closed_early: bool,
|
||||
output: Capture,
|
||||
error: Capture,
|
||||
}
|
||||
impl PipePump {
|
||||
/// This capability comes from the operator, never from the request. It
|
||||
/// must be private to the current trusted monitor identity. Temporary
|
||||
/// captures have zero links and are automatically deleted on drop.
|
||||
pub fn prepare(
|
||||
input: Option<SnapshotFile>,
|
||||
spec: &ValidatedSpec,
|
||||
directory: OwnedFd,
|
||||
) -> io::Result<(Self, ChildPipes)> {
|
||||
let stat = rustix::fs::fstat(&directory)?;
|
||||
if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
|
||||
|| stat.st_uid != rustix::process::geteuid().as_raw()
|
||||
|| stat.st_mode & 0o7777 != 0o700
|
||||
|| !rustix::io::fcntl_getfd(&directory)?.contains(rustix::io::FdFlags::CLOEXEC)
|
||||
{
|
||||
return Err(denied("capture directory capability"));
|
||||
}
|
||||
let limits = &spec.spec().limits;
|
||||
let total = limits
|
||||
.stdout_bytes
|
||||
.checked_add(limits.stderr_bytes)
|
||||
.ok_or_else(|| denied("capture budget overflow"))?;
|
||||
if limits.stdout_bytes > limits.file_bytes
|
||||
|| limits.stderr_bytes > limits.file_bytes
|
||||
|| total
|
||||
> limits
|
||||
.workdir_bytes
|
||||
.min(crate::artifacts::MAX_SNAPSHOT_BYTES)
|
||||
{
|
||||
return Err(denied("capture storage ceiling"));
|
||||
}
|
||||
let (input_end, input_write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?;
|
||||
let (output_read, output_end) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?;
|
||||
let (error_read, error_end) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?;
|
||||
for fd in [&input_write, &output_read, &error_read] {
|
||||
rustix::fs::fcntl_setfl(fd, OFlags::NONBLOCK)?;
|
||||
}
|
||||
let capture = |pipe, limit| -> io::Result<Capture> {
|
||||
let fd = rustix::fs::openat(
|
||||
&directory,
|
||||
".",
|
||||
OFlags::TMPFILE | OFlags::RDWR | OFlags::CLOEXEC,
|
||||
Mode::RUSR | Mode::WUSR,
|
||||
)?;
|
||||
let stat = rustix::fs::fstat(&fd)?;
|
||||
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile
|
||||
|| stat.st_nlink != 0
|
||||
|| stat.st_size != 0
|
||||
|| stat.st_uid != rustix::process::geteuid().as_raw()
|
||||
{
|
||||
return Err(denied("anonymous capture storage"));
|
||||
}
|
||||
Ok(Capture {
|
||||
fd,
|
||||
pipe: Some(pipe),
|
||||
limit,
|
||||
status: CaptureStatus::default(),
|
||||
})
|
||||
};
|
||||
let writer = if input.as_ref().is_some_and(|f| f.bytes() > 0) {
|
||||
Some(input_write)
|
||||
} else {
|
||||
drop(input_write);
|
||||
None
|
||||
};
|
||||
Ok((
|
||||
Self {
|
||||
input,
|
||||
writer,
|
||||
pending: [0; CHUNK],
|
||||
position: 0,
|
||||
length: 0,
|
||||
sent: 0,
|
||||
closed_early: false,
|
||||
output: capture(output_read, limits.stdout_bytes)?,
|
||||
error: capture(error_read, limits.stderr_bytes)?,
|
||||
},
|
||||
ChildPipes {
|
||||
input: input_end,
|
||||
output: output_end,
|
||||
error: error_end,
|
||||
},
|
||||
))
|
||||
}
|
||||
/// Fair bounded tick: one stdin write and one read from each output. The
|
||||
/// outer supervisor supplies poll/deadlines/cancellation; no busy wait here.
|
||||
pub fn poll(&mut self) -> io::Result<PumpStatus> {
|
||||
self.feed()?;
|
||||
self.output.drain()?;
|
||||
self.error.drain()?;
|
||||
Ok(self.status())
|
||||
}
|
||||
pub fn status(&self) -> PumpStatus {
|
||||
PumpStatus {
|
||||
stdin_sent: self.sent,
|
||||
stdin_closed: self.writer.is_none(),
|
||||
stdin_closed_early: self.closed_early,
|
||||
stdout: self.output.status(),
|
||||
stderr: self.error.status(),
|
||||
}
|
||||
}
|
||||
pub fn stdout(&self) -> &Capture {
|
||||
&self.output
|
||||
}
|
||||
pub fn stderr(&self) -> &Capture {
|
||||
&self.error
|
||||
}
|
||||
/// Cancelling closes the feed immediately; outputs can still be drained to
|
||||
/// EOF after the namespace/cgroup terminates. Snapshot stays immutable.
|
||||
pub fn close_input(&mut self) {
|
||||
self.writer = None;
|
||||
}
|
||||
fn feed(&mut self) -> io::Result<()> {
|
||||
let Some(writer) = self.writer.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let input = self
|
||||
.input
|
||||
.as_ref()
|
||||
.ok_or_else(|| denied("stdin ownership"))?;
|
||||
if self.position == self.length {
|
||||
let remaining = input
|
||||
.bytes()
|
||||
.checked_sub(self.sent)
|
||||
.ok_or_else(|| denied("stdin accounting"))?;
|
||||
if remaining == 0 {
|
||||
self.writer = None;
|
||||
return Ok(());
|
||||
}
|
||||
let count = CHUNK.min(remaining as usize);
|
||||
self.length = match rustix::io::pread(input, &mut self.pending[..count], self.sent) {
|
||||
Ok(0) => return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "sealed stdin")),
|
||||
Ok(n) => n,
|
||||
Err(rustix::io::Errno::INTR) => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
self.position = 0;
|
||||
}
|
||||
match write_without_sigpipe(writer, &self.pending[self.position..self.length]) {
|
||||
Ok(0) => return Err(io::Error::new(io::ErrorKind::WriteZero, "stdin pipe")),
|
||||
Ok(n) => {
|
||||
self.position += n;
|
||||
self.sent += n as u64;
|
||||
if self.sent == input.bytes() {
|
||||
self.writer = None;
|
||||
}
|
||||
}
|
||||
Err(rustix::io::Errno::PIPE) => {
|
||||
self.writer = None;
|
||||
self.closed_early = true;
|
||||
}
|
||||
Err(rustix::io::Errno::AGAIN | rustix::io::Errno::INTR) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
fn write_without_sigpipe(fd: &OwnedFd, bytes: &[u8]) -> rustix::io::Result<usize> {
|
||||
// SAFETY: valid owned pipe and borrowed bytes, initialized libc sigsets.
|
||||
// Block only this thread's SIGPIPE during write, consume a newly generated
|
||||
// EPIPE signal if none was pending, and restore the exact prior mask on
|
||||
// every path. Never change process-global disposition or steal a signal
|
||||
// which was already pending. No allocation/unwind while mask is changed.
|
||||
unsafe {
|
||||
let mut set: libc::sigset_t = std::mem::zeroed();
|
||||
let mut old: libc::sigset_t = std::mem::zeroed();
|
||||
let mut pending: libc::sigset_t = std::mem::zeroed();
|
||||
libc::sigemptyset(&mut set);
|
||||
libc::sigaddset(&mut set, libc::SIGPIPE);
|
||||
let rc = libc::pthread_sigmask(libc::SIG_BLOCK, &set, &mut old);
|
||||
if rc != 0 {
|
||||
return Err(rustix::io::Errno::from_raw_os_error(rc));
|
||||
}
|
||||
let result = if libc::sigpending(&mut pending) != 0 {
|
||||
Err(rustix::io::Errno::from_raw_os_error(
|
||||
*libc::__errno_location(),
|
||||
))
|
||||
} else {
|
||||
let prior = libc::sigismember(&pending, libc::SIGPIPE) == 1;
|
||||
let n = libc::write(fd.as_raw_fd(), bytes.as_ptr().cast(), bytes.len());
|
||||
let written = if n < 0 {
|
||||
Err(rustix::io::Errno::from_raw_os_error(
|
||||
*libc::__errno_location(),
|
||||
))
|
||||
} else {
|
||||
Ok(n as usize)
|
||||
};
|
||||
if written == Err(rustix::io::Errno::PIPE) && !prior {
|
||||
let zero = libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
loop {
|
||||
let signal = libc::sigtimedwait(&set, std::ptr::null_mut(), &zero);
|
||||
if signal >= 0
|
||||
|| rustix::io::Errno::from_raw_os_error(*libc::__errno_location())
|
||||
!= rustix::io::Errno::INTR
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
written
|
||||
};
|
||||
let rc = libc::pthread_sigmask(libc::SIG_SETMASK, &old, std::ptr::null_mut());
|
||||
if rc != 0 {
|
||||
return Err(rustix::io::Errno::from_raw_os_error(rc));
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ pub(crate) fn reduce_current(uid: u32, gid: u32) -> io::Result<()> {
|
||||
let uid = Uid::from_raw(uid);
|
||||
rustix::thread::set_thread_res_gid(gid, gid, gid)?;
|
||||
rustix::thread::set_thread_res_uid(uid, uid, uid)?;
|
||||
// The trusted init retains control/capture capabilities. A same-UID user
|
||||
// child must not reopen them through /proc/1/fd or /proc/1/root. Don't rely
|
||||
// on fs.suid_dumpable's system default after the credential transition.
|
||||
rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable)?;
|
||||
let empty = CapabilitySets {
|
||||
effective: CapabilitySet::empty(),
|
||||
permitted: CapabilitySet::empty(),
|
||||
@@ -70,6 +74,7 @@ pub(crate) fn reduce_current(uid: u32, gid: u32) -> io::Result<()> {
|
||||
|| rustix::process::geteuid() != uid
|
||||
|| rustix::process::getgid() != gid
|
||||
|| rustix::process::getegid() != gid
|
||||
|| rustix::process::dumpable_behavior()? != rustix::process::DumpableBehavior::NotDumpable
|
||||
|| rustix::thread::get_keep_capabilities()?
|
||||
|| rustix::thread::capabilities_secure_bits()? != SECURE
|
||||
|| !rustix::thread::no_new_privs()?
|
||||
|
||||
@@ -12,30 +12,7 @@ fn denied(message: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
pub(crate) struct ChildStdio {
|
||||
pub input: OwnedFd,
|
||||
pub output: OwnedFd,
|
||||
pub error: OwnedFd,
|
||||
}
|
||||
impl ChildStdio {
|
||||
pub(crate) fn validate(&self) -> io::Result<()> {
|
||||
for (fd, access) in [
|
||||
(&self.input, rustix::fs::OFlags::RDONLY),
|
||||
(&self.output, rustix::fs::OFlags::WRONLY),
|
||||
(&self.error, rustix::fs::OFlags::WRONLY),
|
||||
] {
|
||||
let s = rustix::fs::fstat(fd)?;
|
||||
if fd.as_raw_fd() < 4
|
||||
|| s.st_mode & libc::S_IFMT != libc::S_IFIFO
|
||||
|| rustix::fs::fcntl_getfl(fd)?.intersection(rustix::fs::OFlags::ACCMODE) != access
|
||||
|| !rustix::io::fcntl_getfd(fd)?.contains(rustix::io::FdFlags::CLOEXEC)
|
||||
{
|
||||
return Err(denied("stdio requires owned CLOEXEC pipe ends"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) use crate::io::ChildPipes as ChildStdio;
|
||||
|
||||
/// Allocate/validate strings before fork. No request shell or host environment.
|
||||
pub(crate) struct ExecPlan {
|
||||
@@ -110,9 +87,12 @@ impl Reaper {
|
||||
}
|
||||
crate::isolation::identity::verify_status(&status, uid, gid)?;
|
||||
crate::isolation::seccomp::filter_count(&status)?;
|
||||
if rustix::process::dumpable_behavior()? != rustix::process::DumpableBehavior::NotDumpable {
|
||||
return Err(denied("trusted init descriptor privacy"));
|
||||
}
|
||||
// PID ownership requires no inherited SIGCHLD auto-reap disposition.
|
||||
check_sigchld()?;
|
||||
stdio.validate()?;
|
||||
stdio.validate_for_exec()?;
|
||||
let (read, write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?;
|
||||
rustix::fs::fcntl_setfl(&read, rustix::fs::OFlags::NONBLOCK)?;
|
||||
let pid = fork_exec(&plan, &stdio, &write)?;
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result<BpfProgram> {
|
||||
libc::SYS_rseq,
|
||||
libc::SYS_rt_sigaction,
|
||||
libc::SYS_rt_sigprocmask,
|
||||
libc::SYS_rt_sigpending,
|
||||
libc::SYS_rt_sigreturn,
|
||||
libc::SYS_sigaltstack,
|
||||
libc::SYS_rt_sigsuspend,
|
||||
@@ -201,6 +202,7 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result<BpfProgram> {
|
||||
rules(
|
||||
[
|
||||
libc::PR_GET_NO_NEW_PRIVS,
|
||||
libc::PR_GET_DUMPABLE,
|
||||
libc::PR_GET_SECCOMP,
|
||||
libc::PR_GET_SECUREBITS,
|
||||
libc::PR_GET_KEEPCAPS,
|
||||
|
||||
@@ -15,5 +15,8 @@ pub mod artifacts;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod cgroup;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod io;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod isolation;
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
static void require(int yes){if(!yes)_exit(2);}
|
||||
static void write_all(int fd,const unsigned char *bytes,size_t n){while(n){ssize_t k=write(fd,bytes,n);if(k<0&&errno==EINTR)continue;require(k>0);bytes+=k;n-=(size_t)k;}}
|
||||
static void pattern(int fd,unsigned char xor){unsigned char bytes[4096];for(unsigned offset=0;offset<262144;offset+=sizeof bytes){for(unsigned i=0;i<sizeof bytes;i++)bytes[i]=(unsigned char)((offset+i)%251)^xor;write_all(fd,bytes,sizeof bytes);}}
|
||||
int main(int argc,char **argv){
|
||||
require(argc==2&&getpid()>1&&getppid()==1&&getuid()==100000&&prctl(PR_GET_SECCOMP)==2);
|
||||
DIR *dir=opendir("/proc/self/fd");require(dir!=NULL);int scanner=dirfd(dir);struct dirent *entry;
|
||||
while((entry=readdir(dir))){if(entry->d_name[0]!='.'){int fd=atoi(entry->d_name);require(fd<=2||fd==scanner);}}
|
||||
closedir(dir);
|
||||
require(open("/proc/1/fd/3",O_RDONLY|O_CLOEXEC)<0&&(errno==EACCES||errno==ENOENT));
|
||||
require(open("/proc/1/root",O_RDONLY|O_DIRECTORY|O_CLOEXEC)<0&&(errno==EACCES||errno==ENOENT));
|
||||
if(!strcmp(argv[1],"empty")){char byte;require(read(0,&byte,1)==0);return 7;}
|
||||
if(!strcmp(argv[1],"early-close")){require(close(0)==0);const unsigned char out[]={'C','L','O','S','E','D',0,255};write_all(1,out,sizeof out);return 7;}
|
||||
if(!strcmp(argv[1],"stdout-limit")||!strcmp(argv[1],"stderr-limit")){
|
||||
pid_t child=fork();require(child>=0);if(!child){for(;;)sleep(10);}
|
||||
unsigned char bytes[4096];memset(bytes,0x5a,sizeof bytes);int fd=!strcmp(argv[1],"stdout-limit")?1:2;
|
||||
// Fixed bounded flood in the independent VM. The descendant holding
|
||||
// both pipe writers must also disappear when the monitor cancels.
|
||||
for(unsigned i=0;i<32;i++){write_all(fd,bytes,sizeof bytes);}
|
||||
for(;;){sleep(10);}
|
||||
}
|
||||
require(!strcmp(argv[1],"duplex")||!strcmp(argv[1],"storage-fail"));pid_t child=fork();require(child>=0);
|
||||
if(!child){pattern(2,0xa5);return 0;}
|
||||
pattern(1,0);unsigned char input[4096];unsigned total=0;
|
||||
for(;;){ssize_t n=read(0,input,sizeof input);if(n<0&&errno==EINTR)continue;require(n>=0);if(!n)break;for(ssize_t i=0;i<n;i++)require(input[i]==(unsigned char)((total+(unsigned)i)%251));total+=(unsigned)n;require(total<=262144);}
|
||||
require(total==262144);int status;require(waitpid(child,&status,0)==child&&WIFEXITED(status)&&WEXITSTATUS(status)==0);return 7;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
#[allow(dead_code)]
|
||||
mod support;
|
||||
use std::process::Command;
|
||||
#[test]
|
||||
#[ignore = "requires independent capped VM with fixed namespace role fixture"]
|
||||
fn independent_stdio_drains_binary_backpressure_eof_early_close_and_limits() {
|
||||
support::require_vm();
|
||||
for mode in [
|
||||
"duplex",
|
||||
"empty",
|
||||
"early-close",
|
||||
"stdout-limit",
|
||||
"stderr-limit",
|
||||
"storage-fail",
|
||||
] {
|
||||
let output = Command::new("/kf/bin/io-probe")
|
||||
.args(["fixed", mode])
|
||||
.env_clear()
|
||||
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{mode}: {:?} {:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let proof = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(proof.contains(&format!("KF_IO_PROOF mode={mode}")));
|
||||
print!("{proof}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires independent capped VM; SIGPIPE default may terminate a broken monitor"]
|
||||
fn early_close_contains_sigpipe_without_global_disposition_or_mask_leak() {
|
||||
support::require_vm();
|
||||
let result = std::process::Command::new("/kf/bin/io-probe")
|
||||
.args(["signal-mask", "early-close"])
|
||||
.env_clear()
|
||||
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{:?} {:?}",
|
||||
result.stdout,
|
||||
result.stderr
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&result.stdout).contains("KF_IO_SIGPIPE_PROOF"));
|
||||
print!("{}", String::from_utf8_lossy(&result.stdout));
|
||||
}
|
||||
Reference in New Issue
Block a user