#![cfg(target_os = "windows")]
use std::os::windows::io::{AsRawHandle, RawHandle};
use std::os::windows::process::CommandExt;
use std::process::{Child, Command};
use std::ptr;
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_SUCCESS, FALSE, HANDLE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicLimitInformation,
JobObjectBasicUIRestrictions, SetInformationJobObject, JOBOBJECT_BASIC_LIMIT_INFORMATION,
JOBOBJECT_BASIC_UI_RESTRICTIONS, JOB_OBJECT_LIMIT_ACTIVE_PROCESS,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_UILIMIT_DESKTOP,
JOB_OBJECT_UILIMIT_DISPLAYSETTINGS, JOB_OBJECT_UILIMIT_EXITWINDOWS,
JOB_OBJECT_UILIMIT_GLOBALATOMS, JOB_OBJECT_UILIMIT_HANDLES, JOB_OBJECT_UILIMIT_READCLIPBOARD,
JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS, JOB_OBJECT_UILIMIT_WRITECLIPBOARD,
};
use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
use super::SandboxTier;
#[link(name = "ntdll")]
extern "system" {
fn NtResumeProcess(process_handle: HANDLE) -> i32;
}
pub struct OwnedJob {
handle: HANDLE,
}
impl OwnedJob {
fn new() -> std::io::Result<Self> {
let h = unsafe { CreateJobObjectW(ptr::null_mut(), ptr::null()) };
if h.is_null() {
return Err(last_os_error("CreateJobObjectW"));
}
Ok(Self { handle: h })
}
pub fn kill_now(&self, exit_code: u32) {
if self.handle.is_null() {
return;
}
unsafe {
use windows_sys::Win32::System::JobObjects::TerminateJobObject;
let _ = TerminateJobObject(self.handle, exit_code);
}
}
}
impl Drop for OwnedJob {
fn drop(&mut self) {
if !self.handle.is_null() {
unsafe {
CloseHandle(self.handle);
}
}
}
}
pub fn apply_to_process(tier: SandboxTier, child: &Child) -> std::io::Result<Option<OwnedJob>> {
if matches!(tier, SandboxTier::None) {
return Ok(None);
}
let job = OwnedJob::new()?;
set_basic_limits(job.handle, tier)?;
if matches!(tier, SandboxTier::Lockdown) {
set_ui_restrictions(job.handle)?;
}
let process_handle: RawHandle = child.as_raw_handle();
let assigned = unsafe { AssignProcessToJobObject(job.handle, process_handle as HANDLE) };
if assigned == FALSE {
return Err(last_os_error("AssignProcessToJobObject"));
}
Ok(Some(job))
}
fn set_basic_limits(job: HANDLE, tier: SandboxTier) -> std::io::Result<()> {
let mut limit_flags = 0u32;
if !matches!(tier, SandboxTier::None) {
limit_flags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
}
if matches!(tier, SandboxTier::Lockdown) {
limit_flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
}
let mut basic: JOBOBJECT_BASIC_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
basic.LimitFlags = limit_flags;
if matches!(tier, SandboxTier::Lockdown) {
basic.ActiveProcessLimit = 1;
}
#[allow(clippy::cast_possible_truncation)]
let size = std::mem::size_of::<JOBOBJECT_BASIC_LIMIT_INFORMATION>() as u32;
let rc = unsafe {
SetInformationJobObject(
job,
JobObjectBasicLimitInformation,
std::ptr::addr_of_mut!(basic).cast(),
size,
)
};
if rc == FALSE {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(87) && limit_flags & JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE != 0 {
limit_flags &= !JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
basic.LimitFlags = limit_flags;
let rc2 = unsafe {
SetInformationJobObject(
job,
JobObjectBasicLimitInformation,
std::ptr::addr_of_mut!(basic).cast(),
size,
)
};
if rc2 == FALSE {
return Err(last_os_error(
"SetInformationJobObject(BasicLimitInformation)",
));
}
return Ok(());
}
return Err(last_os_error(
"SetInformationJobObject(BasicLimitInformation)",
));
}
Ok(())
}
fn set_ui_restrictions(job: HANDLE) -> std::io::Result<()> {
let mut ui = JOBOBJECT_BASIC_UI_RESTRICTIONS {
UIRestrictionsClass: JOB_OBJECT_UILIMIT_DESKTOP
| JOB_OBJECT_UILIMIT_DISPLAYSETTINGS
| JOB_OBJECT_UILIMIT_EXITWINDOWS
| JOB_OBJECT_UILIMIT_GLOBALATOMS
| JOB_OBJECT_UILIMIT_HANDLES
| JOB_OBJECT_UILIMIT_READCLIPBOARD
| JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS
| JOB_OBJECT_UILIMIT_WRITECLIPBOARD,
};
#[allow(clippy::cast_possible_truncation)]
let size = std::mem::size_of::<JOBOBJECT_BASIC_UI_RESTRICTIONS>() as u32;
let rc = unsafe {
SetInformationJobObject(
job,
JobObjectBasicUIRestrictions,
std::ptr::addr_of_mut!(ui).cast(),
size,
)
};
if rc == FALSE {
return Err(last_os_error(
"SetInformationJobObject(BasicUIRestrictions)",
));
}
Ok(())
}
fn last_os_error(label: &str) -> std::io::Error {
let err = unsafe { GetLastError() };
if err == ERROR_SUCCESS {
return std::io::Error::other(format!("{label} failed with no error code"));
}
let raw = i32::try_from(err).unwrap_or(i32::MAX);
std::io::Error::from_raw_os_error(raw)
}
#[must_use]
pub fn available() -> bool {
true
}
pub fn spawn_in_job(
cmd: &mut Command,
tier: SandboxTier,
) -> std::io::Result<(Child, Option<OwnedJob>)> {
if matches!(tier, SandboxTier::None) {
let child = cmd.spawn()?;
return Ok((child, None));
}
cmd.creation_flags(CREATE_SUSPENDED);
let mut child = cmd.spawn()?;
let job = match prepare_job(tier) {
Ok(j) => j,
Err(e) => {
terminate_and_release(&mut child);
return Err(e);
}
};
let process_handle = child.as_raw_handle() as HANDLE;
let assigned = unsafe { AssignProcessToJobObject(job.handle, process_handle) };
if assigned == FALSE {
let err = last_os_error("AssignProcessToJobObject");
terminate_and_release(&mut child);
return Err(err);
}
let status = unsafe { NtResumeProcess(process_handle) };
if status < 0 {
terminate_and_release(&mut child);
return Err(std::io::Error::other(format!(
"NtResumeProcess failed with NTSTATUS 0x{status:x}"
)));
}
Ok((child, Some(job)))
}
fn prepare_job(tier: SandboxTier) -> std::io::Result<OwnedJob> {
let job = OwnedJob::new()?;
set_basic_limits(job.handle, tier)?;
if matches!(tier, SandboxTier::Lockdown) {
set_ui_restrictions(job.handle)?;
}
Ok(job)
}
fn terminate_and_release(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spawn_none_no_job_created() {
let mut cmd = Command::new("cmd");
cmd.args(["/c", "exit", "0"]);
let (mut child, job) = spawn_in_job(&mut cmd, SandboxTier::None).expect("spawn None");
assert!(job.is_none(), "None tier must not create a job");
let status = child.wait().expect("wait for child");
assert!(status.success());
}
#[test]
fn spawn_hardened_creates_job_and_child_exits() {
let mut cmd = Command::new("cmd");
cmd.args(["/c", "exit", "0"]);
let (mut child, job) =
spawn_in_job(&mut cmd, SandboxTier::Hardened).expect("spawn Hardened");
assert!(job.is_some(), "Hardened tier must create a job");
let status = child.wait().expect("wait for child");
assert!(status.success());
}
#[test]
fn spawn_lockdown_creates_job_and_child_exits() {
let mut cmd = Command::new("cmd");
cmd.args(["/c", "exit", "0"]);
let (mut child, job) =
spawn_in_job(&mut cmd, SandboxTier::Lockdown).expect("spawn Lockdown");
assert!(job.is_some(), "Lockdown tier must create a job");
let status = child.wait().expect("wait for child");
assert!(status.success());
}
#[test]
fn apply_to_process_none_returns_none() {
let mut cmd = Command::new("cmd");
cmd.args(["/c", "exit", "0"]);
let mut child = cmd.spawn().expect("spawn child");
let job = apply_to_process(SandboxTier::None, &child).expect("apply None");
assert!(job.is_none());
let _ = child.wait();
}
#[test]
fn apply_to_process_hardened_returns_job() {
let mut cmd = Command::new("cmd");
cmd.args(["/c", "exit", "0"]);
let mut child = cmd.spawn().expect("spawn child");
let job = apply_to_process(SandboxTier::Hardened, &child).expect("apply Hardened");
assert!(job.is_some());
let _ = child.wait();
}
#[test]
fn apply_to_process_lockdown_returns_job() {
let mut cmd = Command::new("cmd");
cmd.args(["/c", "exit", "0"]);
let mut child = cmd.spawn().expect("spawn child");
let job = apply_to_process(SandboxTier::Lockdown, &child).expect("apply Lockdown");
assert!(job.is_some());
let _ = child.wait();
}
#[test]
fn spawn_in_job_nonexistent_binary_fails() {
let mut cmd = Command::new("this_binary_does_not_exist_99999.exe");
let result = spawn_in_job(&mut cmd, SandboxTier::Hardened);
assert!(result.is_err(), "spawning nonexistent binary must fail");
}
}