#[cfg(windows)]
#[allow(dead_code)]
mod win {
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::AsRawHandle;
use std::path::Path;
use windows_sys::Win32::Foundation::{
CloseHandle, LocalFree, SetHandleInformation, ERROR_ALREADY_EXISTS, ERROR_SUCCESS,
GENERIC_ALL, GENERIC_EXECUTE, GENERIC_READ, HANDLE, HANDLE_FLAG_INHERIT, WAIT_OBJECT_0,
};
use windows_sys::Win32::Security::Authorization::{
GetNamedSecurityInfoW, SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W,
GRANT_ACCESS, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, TRUSTEE_IS_GROUP, TRUSTEE_IS_SID,
TRUSTEE_W,
};
use windows_sys::Win32::Security::Isolation::{
CreateAppContainerProfile, DeleteAppContainerProfile,
DeriveAppContainerSidFromAppContainerName,
};
use windows_sys::Win32::Security::{FreeSid, ACL, DACL_SECURITY_INFORMATION, PSID};
use windows_sys::Win32::System::Threading::{
CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess,
InitializeProcThreadAttributeList, TerminateProcess, UpdateProcThreadAttribute,
WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT,
LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION,
PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, STARTF_USESTDHANDLES, STARTUPINFOEXW,
STARTUPINFOW,
};
fn wide(s: impl AsRef<std::ffi::OsStr>) -> Vec<u16> {
s.as_ref().encode_wide().chain(std::iter::once(0)).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Access {
ReadExecute,
Full,
}
impl Access {
fn mask(self) -> u32 {
match self {
Access::ReadExecute => GENERIC_READ | GENERIC_EXECUTE,
Access::Full => GENERIC_ALL,
}
}
}
pub(crate) struct Profile {
name: Vec<u16>,
sid: PSID,
}
unsafe impl Send for Profile {}
unsafe impl Sync for Profile {}
impl Profile {
pub(crate) fn create(name: &str) -> io::Result<Self> {
let name = wide(name);
let display = wide("io-harness sandbox");
let mut sid: PSID = std::ptr::null_mut();
let hr = unsafe {
CreateAppContainerProfile(
name.as_ptr(),
display.as_ptr(),
display.as_ptr(),
std::ptr::null(),
0,
&mut sid,
)
};
if hr < 0 {
let already = hr == hresult_from_win32(ERROR_ALREADY_EXISTS);
if !already {
return Err(io::Error::from_raw_os_error(win32_from_hresult(hr)));
}
let hr =
unsafe { DeriveAppContainerSidFromAppContainerName(name.as_ptr(), &mut sid) };
if hr < 0 {
return Err(io::Error::from_raw_os_error(win32_from_hresult(hr)));
}
}
Ok(Profile { name, sid })
}
pub(crate) fn sid(&self) -> PSID {
self.sid
}
}
impl Drop for Profile {
fn drop(&mut self) {
unsafe {
FreeSid(self.sid);
DeleteAppContainerProfile(self.name.as_ptr());
}
}
}
fn hresult_from_win32(code: u32) -> i32 {
if code == 0 {
return 0;
}
((code & 0x0000_FFFF) | 0x8007_0000) as i32
}
fn win32_from_hresult(hr: i32) -> i32 {
if (hr as u32) & 0xFFFF_0000 == 0x8007_0000 {
(hr as u32 & 0x0000_FFFF) as i32
} else {
hr
}
}
pub(crate) fn grant(path: &Path, sid: PSID, access: Access) -> io::Result<()> {
let wpath = wide(path);
let mut dacl: *mut ACL = std::ptr::null_mut();
let mut sd = std::ptr::null_mut();
let rc = unsafe {
GetNamedSecurityInfoW(
wpath.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut dacl,
std::ptr::null_mut(),
&mut sd,
)
};
if rc != ERROR_SUCCESS {
return Err(io::Error::from_raw_os_error(rc as i32));
}
let _sd = LocalGuard(sd);
let ea = EXPLICIT_ACCESS_W {
grfAccessPermissions: access.mask(),
grfAccessMode: GRANT_ACCESS,
grfInheritance: 3,
Trustee: TRUSTEE_W {
pMultipleTrustee: std::ptr::null_mut(),
MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE,
TrusteeForm: TRUSTEE_IS_SID,
TrusteeType: TRUSTEE_IS_GROUP,
ptstrName: sid.cast(),
},
};
let mut merged: *mut ACL = std::ptr::null_mut();
let rc = unsafe { SetEntriesInAclW(1, &ea, dacl, &mut merged) };
if rc != ERROR_SUCCESS {
return Err(io::Error::from_raw_os_error(rc as i32));
}
let _merged = LocalGuard(merged.cast());
let rc = unsafe {
SetNamedSecurityInfoW(
wpath.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
merged,
std::ptr::null(),
)
};
if rc != ERROR_SUCCESS {
return Err(io::Error::from_raw_os_error(rc as i32));
}
tracing::debug!(path = %path.display(), ?access, "sandbox: granted the AppContainer");
Ok(())
}
struct LocalGuard(*mut core::ffi::c_void);
impl Drop for LocalGuard {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe { LocalFree(self.0) };
}
}
}
pub(crate) struct Spawned {
process: HANDLE,
thread: HANDLE,
reaped: bool,
}
unsafe impl Send for Spawned {}
unsafe impl Sync for Spawned {}
impl Spawned {
pub(crate) fn start(
cmdline: &str,
cwd: &Path,
sid: PSID,
out: &std::fs::File,
) -> io::Result<Self> {
let handle = out.as_raw_handle() as HANDLE;
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }
== 0
{
return Err(io::Error::last_os_error());
}
let mut size: usize = 0;
unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size) };
if size == 0 {
return Err(io::Error::other(
"the process attribute list reported a size of zero",
));
}
let mut buf: Vec<usize> = vec![0; size.div_ceil(size_of::<usize>())];
let list = buf.as_mut_ptr().cast::<core::ffi::c_void>() as LPPROC_THREAD_ATTRIBUTE_LIST;
if unsafe { InitializeProcThreadAttributeList(list, 1, 0, &mut size) } == 0 {
return Err(io::Error::last_os_error());
}
let _attrs = AttrGuard(list);
let caps = windows_sys::Win32::Security::SECURITY_CAPABILITIES {
AppContainerSid: sid,
Capabilities: std::ptr::null_mut(),
CapabilityCount: 0,
Reserved: 0,
};
if unsafe {
UpdateProcThreadAttribute(
list,
0,
PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize,
std::ptr::from_ref(&caps).cast(),
size_of_val(&caps),
std::ptr::null_mut(),
std::ptr::null(),
)
} == 0
{
return Err(io::Error::last_os_error());
}
let mut si = STARTUPINFOEXW {
StartupInfo: STARTUPINFOW {
cb: size_of::<STARTUPINFOEXW>() as u32,
dwFlags: STARTF_USESTDHANDLES,
hStdInput: std::ptr::null_mut(),
hStdOutput: handle,
hStdError: handle,
..Default::default()
},
lpAttributeList: list,
};
let mut pi = PROCESS_INFORMATION::default();
let mut cmd = wide(cmdline);
let wcwd = wide(cwd);
let ok = unsafe {
CreateProcessW(
std::ptr::null(),
cmd.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
1,
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT,
std::ptr::null(),
wcwd.as_ptr(),
std::ptr::from_mut(&mut si).cast::<STARTUPINFOW>(),
&mut pi,
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(Spawned {
process: pi.hProcess,
thread: pi.hThread,
reaped: false,
})
}
pub(crate) fn wait(&mut self, ms: u32) -> io::Result<Option<i32>> {
let w = unsafe { WaitForSingleObject(self.process, ms) };
if w != WAIT_OBJECT_0 {
self.kill();
return Ok(None);
}
let mut code: u32 = 0;
if unsafe { GetExitCodeProcess(self.process, &mut code) } == 0 {
return Err(io::Error::last_os_error());
}
self.reaped = true;
Ok(Some(code as i32))
}
pub(crate) fn kill(&mut self) {
if !self.reaped {
unsafe { TerminateProcess(self.process, 1) };
self.reaped = true;
}
}
}
impl Drop for Spawned {
fn drop(&mut self) {
self.kill();
unsafe {
CloseHandle(self.thread);
CloseHandle(self.process);
}
}
}
struct AttrGuard(LPPROC_THREAD_ATTRIBUTE_LIST);
impl Drop for AttrGuard {
fn drop(&mut self) {
unsafe { DeleteProcThreadAttributeList(self.0) };
}
}
}
#[cfg(all(test, windows))]
mod tests {
use super::win::{grant, Access, Profile, Spawned};
use std::io::Read;
use std::os::windows::process::CommandExt;
fn name(tag: &str) -> String {
format!("io-harness-test-{}-{tag}", std::process::id())
}
fn in_container(tag: &str, cmdline: &str, cwd: &std::path::Path) -> (Option<i32>, String) {
let profile = Profile::create(&name(tag)).unwrap_or_else(|e| {
panic!(
"F1: could not create an AppContainer profile on this host ({e}). This is \
fallback_scope Trigger A: the release's central mechanism is unavailable here."
)
});
grant(cwd, profile.sid(), Access::Full)
.unwrap_or_else(|e| panic!("could not grant the workspace to the container: {e}"));
let out_path = cwd.join("io-harness-out.txt");
let file = std::fs::File::create(&out_path).expect("create the capture file");
let mut child = Spawned::start(cmdline, cwd, profile.sid(), &file)
.unwrap_or_else(|e| panic!("F1: CreateProcessW into the AppContainer failed: {e}"));
drop(file);
let code = child.wait(30_000).expect("wait");
let mut text = String::new();
std::fs::File::open(&out_path)
.expect("reopen the capture file")
.read_to_string(&mut text)
.ok();
(code, text)
}
fn outside(cmdline: &str, cwd: &std::path::Path) -> Option<i32> {
std::process::Command::new("cmd.exe")
.raw_arg(format!("/c {cmdline}"))
.current_dir(cwd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("the control command must at least run")
.code()
}
#[test]
fn an_appcontainer_can_be_created_and_entered() {
let dir = tempfile::tempdir().expect("tempdir");
let (code, out) = in_container(
"smoke",
"cmd.exe /c echo io-harness-appcontainer-smoke",
dir.path(),
);
assert_eq!(
code,
Some(0),
"the payload ran inside the container but did not exit 0; output was {out:?}"
);
assert!(
out.contains("io-harness-appcontainer-smoke"),
"the redirect did not reach the capture file; a handle that is not inheritable \
produces exactly this — a process that starts, runs and writes nothing. Got {out:?}"
);
}
#[test]
fn a_payload_cannot_read_what_it_was_not_granted() {
let work = tempfile::tempdir().expect("tempdir");
let vault = tempfile::tempdir().expect("tempdir");
let secret = vault.path().join("secret.txt");
std::fs::write(&secret, b"io-harness-secret").expect("write the secret");
let line = format!("cmd.exe /c type \"{}\"", secret.display());
assert_eq!(
outside(&format!("type \"{}\"", secret.display()), work.path()),
Some(0),
"the negative control failed: this file is unreadable even outside the container, \
so a refusal inside it would prove nothing"
);
let (code, out) = in_container("read", &line, work.path());
assert_ne!(
code,
Some(0),
"the container read a file it was never granted; output was {out:?}"
);
assert!(
!out.contains("io-harness-secret"),
"the container printed the secret's contents: {out:?}"
);
}
#[test]
fn a_payload_has_no_route_off_the_machine() {
let work = tempfile::tempdir().expect("tempdir");
let probe = "curl.exe -s -m 15 -o NUL https://example.com";
assert_eq!(
outside(probe, work.path()),
Some(0),
"the negative control failed: this host has no outbound network even outside a \
container (or no curl.exe), so a failure inside one would prove nothing"
);
let (code, out) = in_container("net", &format!("cmd.exe /c {probe}"), work.path());
assert_ne!(
code,
Some(0),
"the container reached the network with an empty capability array; there is no \
`internetClient` on this profile, so this means the capability set is not being \
applied. Output was {out:?}"
);
}
#[test]
fn a_binary_nothing_blessed_runs_once_its_own_directory_is_granted() {
let exe = std::env::current_exe().expect("the test binary knows where it is");
let bin_dir = exe.parent().expect("it has a directory").to_path_buf();
let work = tempfile::tempdir().expect("tempdir");
let profile = Profile::create(&name("toolchain")).expect("profile");
grant(work.path(), profile.sid(), Access::Full).expect("grant the workspace");
grant(&bin_dir, profile.sid(), Access::ReadExecute).expect("grant the binary's directory");
let out_path = work.path().join("o.txt");
let file = std::fs::File::create(&out_path).expect("capture");
let line = format!(
"\"{}\" --list --exact io-harness-no-such-test",
exe.display()
);
let mut child =
Spawned::start(&line, work.path(), profile.sid(), &file).expect("spawn the payload");
let code = child.wait(60_000).expect("wait");
let mut text = String::new();
std::fs::File::open(&out_path)
.and_then(|mut f| f.read_to_string(&mut text))
.ok();
assert_eq!(
code,
Some(0),
"a payload nothing had blessed could not execute inside the container even with \
its own directory granted read-and-execute. This is fallback_scope Trigger B: the \
grant set is a discovery problem rather than a configuration one. Output: {text:?}"
);
}
#[test]
fn the_wall_clock_kills_only_what_overruns() {
let dir = tempfile::tempdir().expect("tempdir");
let profile = Profile::create(&name("wall")).expect("profile");
grant(dir.path(), profile.sid(), Access::Full).expect("grant");
let file = std::fs::File::create(dir.path().join("o.txt")).expect("capture");
let mut slow = Spawned::start(
"cmd.exe /c for /L %i in (1,1,2000000000) do @rem",
dir.path(),
profile.sid(),
&file,
)
.expect("spawn the slow payload");
assert_eq!(
slow.wait(1_000).expect("wait"),
None,
"a payload that runs for thirty seconds must be capped by a one-second ceiling"
);
let mut quick =
Spawned::start("cmd.exe /c exit 7", dir.path(), profile.sid(), &file).expect("spawn");
assert_eq!(
quick.wait(30_000).expect("wait"),
Some(7),
"a payload that finishes inside the ceiling must report its own exit code, not a cap"
);
}
}