feat(sandbox): add bounded standalone launcher control

This commit is contained in:
2026-10-06 22:04:29 +08:00
parent 957135a112
commit 08db00b56e
8 changed files with 779 additions and 4 deletions
+26
View File
@@ -119,6 +119,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -379,6 +385,7 @@ version = "0.0.1"
dependencies = [
"clap",
"kf-contracts",
"rustix",
"serde",
"serde_json",
]
@@ -406,6 +413,12 @@ version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
@@ -518,6 +531,19 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
+30 -2
View File
@@ -91,8 +91,36 @@ FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜
最先停止的原因只用于调度诊断,最终执行分类仍由结果模型依据全部观察确定。
事件仅由可信监督器在核验同一任务的实际操作后产生,不可从执行请求直接反序列化。
该模块不执行系统调用、不证明隔离、不产生执行许可;有界 IPC、单线程启动器、真实终止与
清理仍待实现和独立 VM 验证。当前 CLI `run` 保持拒绝执行。
该状态模块不执行系统调用、不证明隔离、不产生执行许可;真实任务树终止与清理仍待实现和
独立 VM 验证。当前 CLI `run` 保持拒绝执行。
## 独立启动入口与控制通信
`launcher/entry.rs` 在可信二进制的独立进程中运行,不启动 Tokio、不安装自定义 `pre_exec`
闭包,也不在 Worker 的 fork 子进程里运行 Rust 初始化逻辑。入口实际检查 `/proc/self/task`
只有一个线程;未来每次权限操作或 fork 前须再次检查。当前入口以普通身份运行,没有实现
特权 helper、namespace、降权、seccomp 或用户程序执行,初始化返回准备阶段的能力不可用。
控制端必须先在可信 state 根下创建独占、权限 0700 的每次启动目录和 Unix listener,再通过
清空环境的 `Command` 启动内部入口;socket 不放在用户工作区,控制通道与用户 stdio 分开。
入口通过非阻塞、CLOEXEC socket 连接,队列满时直接失败,不在连接阶段无限等待。
双方用内核 `SO_PEERCRED` 核对预期 PID/UID/GID;父端 PID 来源于仍持有的 `Child`,
子端父 PID 来源于内核。内部入口不提供用户命令参数,握手消息不能转换成生命周期确认事件。
`launcher/protocol.rs` 使用带版本的 8 字节帧头和最多 1 KiB JSON。先拒绝未知版本、标志及
非法长度,再读取固定大小缓冲;发送序列化也使用固定缓冲。父子消息类型分开,拒绝多余字段、
非法 run_id 和未知命令。所有 I/O 共用一个绝对截止时间(入口 5 秒,通道最大 10 秒),
部分帧不能续期;EOF、解析、超时或写入错误关闭并锁定通道,不能重新同步后继续初始化。
新增 `rustix` 1.1.5 仅用于沙箱的安全 syscall/FD 封装,启用 `net`、`process`,由锁文件固定。
选择它的 OwnedFd/AsFd 接口完成连接与凭据核对;目前无需 `nix` 或直接 `libc` 调用,
也未放宽 `unsafe` 限制。参见 [rustix](https://github.com/bytecodealliance/rustix)、
[socket_peercred](https://docs.rs/rustix/1.1.5/rustix/net/sockopt/fn.socket_peercred.html) 和
[fork 后的安全约束](https://doc.rust-lang.org/std/os/unix/process/trait.CommandExt.html#tymethod.pre_exec)。
普通 Linux 回归实际启动可信入口,验证单线程、认证握手、准备拒绝、取消和断开后的退出回收;
协议回归验证边界与失败锁定。这些测试没有运行用户代码或安全攻击,不是 Linux 隔离验收。
目标进程的阻塞/放行、完整 FD 白名单、进程树监督、阶段回滚及独立 VM 安全验收仍待完成。
## 可信运行配置与启动前检查
+1
View File
@@ -10,6 +10,7 @@ kf-contracts.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
rustix = { version = "1.1.5", features = ["net", "process"] }
[lints]
workspace = true
@@ -0,0 +1,79 @@
//! Runs after a fresh exec of the trusted binary, without a Tokio runtime.
//! No custom pre_exec closure, fork, user program, or permission change occurs.
use super::protocol::{ChildMessage, ControlChannel, ExpectedPeer, ParentMessage};
use crate::outcome::{ErrorCode, ExecutionStage};
use std::io;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::{Duration, Instant};
pub const STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
/// Parent must create a listener in a fresh private directory under its trusted
/// state root before spawning this entry. Socket path is trusted deployment data.
/// The kernel peer credentials, rather than that path, authenticate the parent.
pub fn serve(socket_path: &Path) -> io::Result<()> {
let deadline = Instant::now() + STARTUP_TIMEOUT;
ensure_single_threaded()?;
let parent = rustix::process::getppid()
.filter(|pid| *pid != rustix::process::Pid::INIT)
.ok_or_else(|| {
io::Error::new(io::ErrorKind::PermissionDenied, "missing launcher parent")
})?;
// A full/stalled listener queue must fail, rather than block before the
// channel deadline is installed. OwnedFd closes on every failure path.
let socket = rustix::net::socket_with(
rustix::net::AddressFamily::UNIX,
rustix::net::SocketType::STREAM,
rustix::net::SocketFlags::CLOEXEC | rustix::net::SocketFlags::NONBLOCK,
None,
)?;
let address = rustix::net::SocketAddrUnix::new(socket_path)?;
rustix::net::connect(&socket, &address)?;
let stream = UnixStream::from(socket);
let mut channel = ControlChannel::new(
stream,
ExpectedPeer {
pid: parent.as_raw_pid() as u32,
uid: rustix::process::geteuid().as_raw(),
gid: rustix::process::getegid().as_raw(),
},
deadline,
)?;
channel.send(&ChildMessage::Hello {
pid: std::process::id(),
threads: 1,
})?;
match channel.receive::<ParentMessage>()? {
ParentMessage::Initialize { run_id } => {
// A transport handshake is never an isolation or exec permit.
channel.send(&ChildMessage::InitializationRefused {
run_id,
stage: ExecutionStage::Preparation,
code: ErrorCode::CapabilityUnavailable,
})?;
}
ParentMessage::Cancel {} => channel.send(&ChildMessage::Cancelled {})?,
}
Ok(())
}
/// Must be repeated immediately before future privileged/fork operations.
pub fn ensure_single_threaded() -> io::Result<()> {
let mut count = 0;
for entry in std::fs::read_dir("/proc/self/task")? {
entry?;
count += 1;
if count > 1 {
break;
}
}
if count != 1 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"launcher requires one thread",
));
}
Ok(())
}
+7 -2
View File
@@ -1,7 +1,12 @@
//! Trusted supervisor/launcher coordination, separate from user stdio.
//!
//! Lifecycle events describe completed operations, not commands received from
//! an untrusted request. OS operations and authenticated bounded IPC are pending;
//! this module grants no execution authority and does not change CLI refusal.
//! an untrusted request. The standalone entry authenticates its parent and uses
//! bounded IPC, but refuses initialization until OS isolation is implemented.
pub mod lifecycle;
#[cfg(target_os = "linux")]
pub mod entry;
#[cfg(target_os = "linux")]
pub mod protocol;
@@ -0,0 +1,314 @@
//! Private per-attempt Unix control transport, never user stdin/stdout.
//! Messages cannot produce Lifecycle events or prove completed OS operations.
use crate::outcome::{ErrorCode, ExecutionStage};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::io::{self, Read, Write};
use std::net::Shutdown;
use std::os::unix::net::UnixStream;
use std::time::{Duration, Instant};
pub const MAX_MESSAGE_BYTES: usize = 1024;
pub const MAX_CONTROL_TIMEOUT: Duration = Duration::from_secs(10);
const PREFIX: [u8; 4] = [b'K', b'F', 1, 0];
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case", deny_unknown_fields)]
pub enum ParentMessage {
Initialize { run_id: String },
Cancel {},
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChildMessage {
Hello {
pid: u32,
threads: u8,
},
InitializationRefused {
run_id: String,
stage: ExecutionStage,
code: ErrorCode,
},
Cancelled {},
}
mod sealed {
pub trait Sealed {}
}
/// Sealed to keep the control protocol separate from arbitrary execution data.
pub trait WireMessage: sealed::Sealed + Serialize + DeserializeOwned {
fn valid(&self) -> bool;
}
impl sealed::Sealed for ParentMessage {}
impl WireMessage for ParentMessage {
fn valid(&self) -> bool {
match self {
Self::Initialize { run_id } => valid_run_id(run_id),
Self::Cancel {} => true,
}
}
}
impl sealed::Sealed for ChildMessage {}
impl WireMessage for ChildMessage {
fn valid(&self) -> bool {
match self {
Self::Hello { pid, threads } => *pid > 0 && *threads == 1,
Self::InitializationRefused { run_id, .. } => valid_run_id(run_id),
Self::Cancelled {} => true,
}
}
}
fn valid_run_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"_-".contains(&byte))
}
/// Expected credentials come from the spawned Child handle / actual parent,
/// never the JSON payload. A separate stream is required for every attempt.
#[derive(Clone, Copy, Debug)]
pub struct ExpectedPeer {
pub pid: u32,
pub uid: u32,
pub gid: u32,
}
pub struct ControlChannel {
stream: UnixStream,
deadline: Instant,
poisoned: bool,
}
impl ControlChannel {
pub fn new(stream: UnixStream, peer: ExpectedPeer, deadline: Instant) -> io::Result<Self> {
let remaining = deadline
.checked_duration_since(Instant::now())
.filter(|duration| !duration.is_zero() && *duration <= MAX_CONTROL_TIMEOUT)
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "invalid control deadline")
})?;
let credentials = rustix::net::sockopt::socket_peercred(&stream)?;
if credentials.pid.as_raw_pid() as u32 != peer.pid
|| credentials.uid.as_raw() != peer.uid
|| credentials.gid.as_raw() != peer.gid
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"unexpected control peer",
));
}
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(remaining))?;
stream.set_write_timeout(Some(remaining))?;
Ok(Self {
stream,
deadline,
poisoned: false,
})
}
pub fn send<T: WireMessage>(&mut self, message: &T) -> io::Result<()> {
let result = self.send_inner(message);
self.finish(result)
}
pub fn receive<T: WireMessage>(&mut self) -> io::Result<T> {
let result = self.receive_inner();
self.finish(result)
}
fn send_inner<T: WireMessage>(&mut self, message: &T) -> io::Result<()> {
self.remaining()?;
if !message.valid() {
return Err(invalid("invalid control message"));
}
let mut body = FixedBuffer::default();
serde_json::to_writer(&mut body, message)
.map_err(|_| invalid("control message too large"))?;
let mut header = [0u8; 8];
header[..4].copy_from_slice(&PREFIX);
header[4..].copy_from_slice(&(body.len as u32).to_be_bytes());
self.write_bytes(&header)?;
self.write_bytes(&body.bytes[..body.len])
}
fn receive_inner<T: WireMessage>(&mut self) -> io::Result<T> {
let mut header = [0u8; 8];
self.read_bytes(&mut header)?;
let length = decode_header(header)?;
let mut bytes = [0u8; MAX_MESSAGE_BYTES];
self.read_bytes(&mut bytes[..length])?;
let message: T = serde_json::from_slice(&bytes[..length])
.map_err(|_| invalid("invalid control JSON"))?;
if !message.valid() {
return Err(invalid("invalid control message"));
}
Ok(message)
}
fn remaining(&self) -> io::Result<Duration> {
if self.poisoned {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"control channel failed",
));
}
self.deadline
.checked_duration_since(Instant::now())
.filter(|duration| !duration.is_zero())
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "control deadline expired"))
}
fn read_bytes(&mut self, mut bytes: &mut [u8]) -> io::Result<()> {
while !bytes.is_empty() {
self.stream.set_read_timeout(Some(self.remaining()?))?;
match self.stream.read(bytes) {
Ok(0) => return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "control EOF")),
Ok(count) => bytes = &mut bytes[count..],
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
Ok(())
}
fn write_bytes(&mut self, mut bytes: &[u8]) -> io::Result<()> {
while !bytes.is_empty() {
self.stream.set_write_timeout(Some(self.remaining()?))?;
match self.stream.write(bytes) {
Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"control write failed",
));
}
Ok(count) => bytes = &bytes[count..],
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
Ok(())
}
fn finish<T>(&mut self, result: io::Result<T>) -> io::Result<T> {
if result.is_err() {
self.poisoned = true;
let _ = self.stream.shutdown(Shutdown::Both);
}
result
}
}
fn invalid(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
fn decode_header(header: [u8; 8]) -> io::Result<usize> {
if header[..4] != PREFIX {
return Err(invalid("unsupported control framing"));
}
let length =
u32::from_be_bytes(header[4..].try_into().expect("four-byte header length")) as usize;
if length == 0 || length > MAX_MESSAGE_BYTES {
return Err(invalid("invalid control frame length"));
}
Ok(length)
}
struct FixedBuffer {
bytes: [u8; MAX_MESSAGE_BYTES],
len: usize,
}
impl Default for FixedBuffer {
fn default() -> Self {
Self {
bytes: [0; MAX_MESSAGE_BYTES],
len: 0,
}
}
}
impl Write for FixedBuffer {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if bytes.len() > MAX_MESSAGE_BYTES - self.len {
return Err(invalid("control message too large"));
}
self.bytes[self.len..self.len + bytes.len()].copy_from_slice(bytes);
self.len += bytes.len();
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_rejects_oversized_length_before_body_read() {
for length in [0, 1025, u32::MAX] {
let mut header = [0u8; 8];
header[..4].copy_from_slice(&PREFIX);
header[4..].copy_from_slice(&length.to_be_bytes());
assert!(decode_header(header).is_err());
}
}
#[test]
fn header_rejects_version_and_reserved_flags() {
for prefix in [[b'K', b'F', 2, 0], [b'K', b'F', 1, 1], [0; 4]] {
let mut header = [0u8; 8];
header[..4].copy_from_slice(&prefix);
header[4..].copy_from_slice(&1u32.to_be_bytes());
assert!(decode_header(header).is_err());
}
}
#[test]
fn serializer_has_fixed_storage() {
let mut buffer = FixedBuffer::default();
buffer.write_all(&[0; MAX_MESSAGE_BYTES]).unwrap();
assert!(buffer.write_all(b"extra").is_err());
assert_eq!(buffer.len, MAX_MESSAGE_BYTES);
}
#[test]
fn schemas_reject_unknown_fields_and_direction() {
assert!(
serde_json::from_str::<ParentMessage>(r#"{"command":"cancel","exec":true}"#).is_err()
);
assert!(serde_json::from_str::<ChildMessage>(r#"{"command":"cancel"}"#).is_err());
assert!(serde_json::from_str::<ParentMessage>(r#"{"command":"release_exec"}"#).is_err());
}
#[test]
fn identifiers_and_thread_claims_are_bounded() {
for run_id in ["", "../work", "bad id"] {
assert!(
!ParentMessage::Initialize {
run_id: run_id.into()
}
.valid()
);
}
assert!(
!ParentMessage::Initialize {
run_id: "x".repeat(65)
}
.valid()
);
assert!(!ChildMessage::Hello { pid: 1, threads: 2 }.valid());
assert!(!ChildMessage::Hello { pid: 0, threads: 1 }.valid());
}
}
+19
View File
@@ -28,6 +28,13 @@ enum Command {
},
/// Reserved execution entrypoint; refuses execution until isolation is implemented.
Run,
/// Internal trusted launcher control entry; never runs a user program.
#[cfg(target_os = "linux")]
#[command(hide = true)]
Launcher {
#[arg(long)]
control_socket: PathBuf,
},
}
fn main() -> ExitCode {
@@ -50,6 +57,18 @@ fn main() -> ExitCode {
}
Command::Validate { spec } => validate_spec(spec),
Command::CheckRuntime { config } => check_runtime(config),
#[cfg(target_os = "linux")]
Command::Launcher { control_socket } => {
match kf_sandbox::launcher::entry::serve(&control_socket) {
Ok(()) => ExitCode::from(2),
Err(error) => spec_error(
"launcher_control_failed",
Some(serde_json::json!({
"io_kind": format!("{:?}", error.kind())
})),
),
}
}
}
}
@@ -0,0 +1,303 @@
#![cfg(target_os = "linux")]
use kf_sandbox::launcher::protocol::{ChildMessage, ControlChannel, ExpectedPeer, ParentMessage};
use kf_sandbox::outcome::{ErrorCode, ExecutionStage};
use std::fs::{self, DirBuilder};
use std::io::{self, Write};
use std::os::unix::fs::DirBuilderExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
static ATTEMPT: AtomicU64 = AtomicU64::new(0);
struct Attempt {
directory: PathBuf,
child: Option<Child>,
}
impl Attempt {
fn start() -> (Self, ControlChannel, u32) {
let directory = std::env::temp_dir().join(format!(
"kf-l-{}-{}",
std::process::id(),
ATTEMPT.fetch_add(1, Ordering::Relaxed)
));
DirBuilder::new().mode(0o700).create(&directory).unwrap();
let mut attempt = Self {
directory,
child: None,
};
let socket = attempt.directory.join("control.sock");
let listener = UnixListener::bind(&socket).unwrap();
listener.set_nonblocking(true).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let child = Command::new(env!("CARGO_BIN_EXE_kf-sandbox"))
.arg("launcher")
.arg("--control-socket")
.arg(&socket)
.env_clear()
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let pid = child.id();
attempt.child = Some(child);
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
assert!(
Instant::now() < deadline,
"launcher must connect within deadline"
);
assert!(
attempt
.child
.as_mut()
.unwrap()
.try_wait()
.unwrap()
.is_none()
);
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("cannot accept launcher: {error}"),
}
};
let channel = ControlChannel::new(stream, own_peer(pid), deadline).unwrap();
assert_eq!(
fs::read_dir(format!("/proc/{pid}/task")).unwrap().count(),
1
);
(attempt, channel, pid)
}
fn reap(&mut self) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = self.child.as_mut().unwrap().try_wait().unwrap() {
assert_eq!(
status.code(),
Some(2),
"entry never reports execution success"
);
self.child = None;
return;
}
assert!(
Instant::now() < deadline,
"launcher must exit and be reaped"
);
std::thread::sleep(Duration::from_millis(5));
}
}
}
impl Drop for Attempt {
fn drop(&mut self) {
if let Some(child) = &mut self.child {
let _ = child.kill();
let _ = child.wait();
}
let _ = fs::remove_file(self.directory.join("control.sock"));
let _ = fs::remove_dir(&self.directory);
}
}
fn own_peer(pid: u32) -> ExpectedPeer {
ExpectedPeer {
pid,
uid: rustix::process::geteuid().as_raw(),
gid: rustix::process::getegid().as_raw(),
}
}
fn pair() -> (ControlChannel, UnixStream) {
let (stream, writer) = UnixStream::pair().unwrap();
(
ControlChannel::new(
stream,
own_peer(std::process::id()),
Instant::now() + Duration::from_secs(2),
)
.unwrap(),
writer,
)
}
#[test]
fn fresh_single_thread_entry_exchanges_authenticated_messages_then_refuses() {
let (mut attempt, mut channel, pid) = Attempt::start();
assert_eq!(
channel.receive::<ChildMessage>().unwrap(),
ChildMessage::Hello { pid, threads: 1 }
);
channel
.send(&ParentMessage::Initialize {
run_id: "attempt-001".into(),
})
.unwrap();
assert_eq!(
channel.receive::<ChildMessage>().unwrap(),
ChildMessage::InitializationRefused {
run_id: "attempt-001".into(),
stage: ExecutionStage::Preparation,
code: ErrorCode::CapabilityUnavailable,
}
);
attempt.reap();
assert_eq!(
channel.receive::<ChildMessage>().unwrap_err().kind(),
io::ErrorKind::UnexpectedEof
);
}
#[test]
fn parent_can_cancel_before_initialization_and_reap_entry() {
let (mut attempt, mut channel, pid) = Attempt::start();
assert_eq!(
channel.receive::<ChildMessage>().unwrap(),
ChildMessage::Hello { pid, threads: 1 }
);
channel.send(&ParentMessage::Cancel {}).unwrap();
assert_eq!(
channel.receive::<ChildMessage>().unwrap(),
ChildMessage::Cancelled {}
);
attempt.reap();
}
#[test]
fn parent_disconnect_does_not_leave_a_waiting_launcher() {
let (mut attempt, mut channel, _) = Attempt::start();
assert!(matches!(
channel.receive::<ChildMessage>().unwrap(),
ChildMessage::Hello { .. }
));
drop(channel);
attempt.reap();
}
#[test]
fn kernel_credentials_reject_an_unexpected_peer() {
for peer in [
ExpectedPeer {
pid: std::process::id() + 1,
..own_peer(std::process::id())
},
ExpectedPeer {
uid: rustix::process::geteuid().as_raw() + 1,
..own_peer(std::process::id())
},
ExpectedPeer {
gid: rustix::process::getegid().as_raw() + 1,
..own_peer(std::process::id())
},
] {
let (stream, _writer) = UnixStream::pair().unwrap();
let result = ControlChannel::new(stream, peer, Instant::now() + Duration::from_secs(1));
assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::PermissionDenied));
}
}
#[test]
fn truncated_frame_poisoning_prevents_resynchronization() {
let (mut channel, mut writer) = pair();
writer.write_all(b"KF\x01").unwrap();
drop(writer);
assert_eq!(
channel.receive::<ParentMessage>().unwrap_err().kind(),
io::ErrorKind::UnexpectedEof
);
assert_eq!(
channel.send(&ParentMessage::Cancel {}).unwrap_err().kind(),
io::ErrorKind::BrokenPipe
);
}
#[test]
fn two_frames_remain_separate_and_keep_their_direction() {
let (mut channel, stream) = pair();
let mut writer = ControlChannel::new(
stream,
own_peer(std::process::id()),
Instant::now() + Duration::from_secs(2),
)
.unwrap();
writer
.send(&ParentMessage::Initialize {
run_id: "run-1".into(),
})
.unwrap();
writer.send(&ParentMessage::Cancel {}).unwrap();
assert_eq!(
channel.receive::<ParentMessage>().unwrap(),
ParentMessage::Initialize {
run_id: "run-1".into()
}
);
assert_eq!(
channel.receive::<ParentMessage>().unwrap(),
ParentMessage::Cancel {}
);
}
#[test]
fn unknown_fields_poison_channel_before_any_later_command() {
let (mut channel, mut writer) = pair();
let bytes = br#"{"command":"cancel","extra":true}"#;
writer.write_all(b"KF\x01\x00").unwrap();
writer
.write_all(&(bytes.len() as u32).to_be_bytes())
.unwrap();
writer.write_all(bytes).unwrap();
assert_eq!(
channel.receive::<ParentMessage>().unwrap_err().kind(),
io::ErrorKind::InvalidData
);
assert_eq!(
channel.receive::<ParentMessage>().unwrap_err().kind(),
io::ErrorKind::BrokenPipe
);
}
#[test]
fn partial_body_cannot_reset_the_absolute_deadline() {
let (stream, mut writer) = UnixStream::pair().unwrap();
let mut channel = ControlChannel::new(
stream,
own_peer(std::process::id()),
Instant::now() + Duration::from_millis(80),
)
.unwrap();
writer.write_all(b"KF\x01\x00\x00\x00\x00\x10{").unwrap();
let started = Instant::now();
let error = channel.receive::<ParentMessage>().unwrap_err();
assert!(matches!(
error.kind(),
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
));
assert!(started.elapsed() < Duration::from_secs(1));
assert_eq!(
channel.send(&ParentMessage::Cancel {}).unwrap_err().kind(),
io::ErrorKind::BrokenPipe
);
}
#[test]
fn deadline_must_be_finite_short_and_in_the_future() {
for deadline in [
Instant::now() - Duration::from_secs(1),
Instant::now() + Duration::from_secs(30),
] {
let (stream, _writer) = UnixStream::pair().unwrap();
assert!(
matches!(ControlChannel::new(stream, own_peer(std::process::id()), deadline),
Err(error) if error.kind() == io::ErrorKind::InvalidInput)
);
}
}