feat(sandbox): 添加明确的标准输入输出通道与有界帧传输

This commit is contained in:
2026-09-09 00:26:06 +08:00
parent 0a347bee6d
commit 237ab0f952
9 changed files with 409 additions and 8 deletions
@@ -774,6 +774,90 @@ mod tests {
Some(0)
);
drop(running);
// Actual native RPC: the child cannot name an identity or connect to
// a shared endpoint; only its own stdio pipe reaches this broker.
{
use std::{
io::{BufReader, Read},
os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle},
};
let sentinel = unsafe {
windows_sys::Win32::System::Threading::CreateEventW(
std::ptr::null(),
1,
0,
std::ptr::null(),
)
};
assert!(!sentinel.is_null());
let sentinel = unsafe { OwnedHandle::from_raw_handle(sentinel) };
assert_ne!(
unsafe {
windows_sys::Win32::Foundation::SetHandleInformation(
sentinel.as_raw_handle(),
windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT,
windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT,
)
},
0
);
let vault = tempfile::tempdir().unwrap();
let mut workspace = crate::workspace::Workspace::open(vault.path()).unwrap();
workspace
.write("fixture.md", "", b"from host broker", "local")
.unwrap();
let mut rpc = claims.clone();
rpc.vault_id = workspace.vault_id.clone();
rpc.arguments = vec![
"file_rpc".into(),
(sentinel.as_raw_handle() as usize).to_string(),
];
rpc.permissions.insert("notes.read".into());
rpc.expires_at_ms = 10_000;
let permit = authority.issue(&rpc, 1).unwrap();
let rpc_context = Context {
vault_id: &rpc.vault_id,
..context
};
let prepared = rpc_context
.prepare(&authority, &permit, &rpc, &bound_entry, &broker, 2)
.unwrap();
let mut files = crate::extension_file_broker::Broker::bind(
&authority, &permit, &rpc, &broker, &workspace, "1", 2,
)
.unwrap();
let (suspended, io) = prepared
.create_suspended_with_stdio(&profile, &bound_entry)
.unwrap();
let running = unsafe { suspended.resume().unwrap() };
let crate::extension_stdio::HostIo {
mut input,
output,
mut error,
} = io;
let mut output = crate::extension_stdio::Frames::new(BufReader::new(output));
let request = output.read().unwrap().unwrap();
let response = files.dispatch(&mut workspace, &request).unwrap();
crate::extension_stdio::write_frame(
&mut input,
&serde_json::to_vec(&response).unwrap(),
)
.unwrap();
drop(input);
assert_eq!(
running.wait(std::time::Duration::from_secs(5)).unwrap(),
Some(0)
);
assert_eq!(output.read().unwrap().unwrap(), b"{\"ok\":true}");
assert!(output.read().unwrap().is_none());
let mut diagnostic = String::new();
error.read_to_string(&mut diagnostic).unwrap();
assert_eq!(diagnostic.trim(), "fixture diagnostic");
assert_eq!(
workspace.read("fixture.md").unwrap().content,
"from host broker"
);
}
for cause in [
"before_create",
"before_resume",
@@ -19,7 +19,7 @@ use std::{
};
use windows_sys::Win32::Storage::FileSystem::*;
pub const MAX_FRAME_BYTES: usize = 2 * 1024 * 1024;
pub use crate::extension_stdio::MAX_FRAME_BYTES;
pub const MAX_NOTE_BYTES: usize = 1024 * 1024;
const REQUESTS_PER_SECOND: u32 = 32;
#[derive(Deserialize)]
@@ -50,6 +50,30 @@ impl PreparedLaunch {
lease: self.lease,
})
}
pub fn create_suspended_with_stdio<'a>(
self,
profile: &'a crate::extension_container::Profile,
entry: &'a BoundEntry<'a>,
) -> Result<(LeasedSuspended<'a>, crate::extension_stdio::HostIo)> {
self.lease.check()?;
if self.path != entry.path()
|| self.entry != entry.relative_name()
|| self.tree != entry.tree_sha256()
{
return Err(HostError::new("EXTENSION_ENTRY_PERMIT_MISMATCH"));
}
let (process, io) = crate::extension_process::Suspended::create_bound_with_stdio(
profile, entry, self.data,
)?;
self.lease.check()?;
Ok((
LeasedSuspended {
process,
lease: self.lease,
},
io,
))
}
}
impl<'a> LeasedSuspended<'a> {
/// # Safety
+55 -6
View File
@@ -26,11 +26,11 @@ struct Attributes {
initialized: bool,
}
impl Attributes {
fn new() -> Result<Self> {
fn new(count: u32) -> Result<Self> {
let bad = || HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED");
let mut bytes = 0;
unsafe {
InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut bytes);
InitializeProcThreadAttributeList(std::ptr::null_mut(), count, 0, &mut bytes);
}
if bytes == 0 || bytes > 65536 {
return Err(bad());
@@ -40,7 +40,12 @@ impl Attributes {
initialized: false,
};
if unsafe {
InitializeProcThreadAttributeList(value.buffer.as_mut_ptr().cast(), 1, 0, &mut bytes)
InitializeProcThreadAttributeList(
value.buffer.as_mut_ptr().cast(),
count,
0,
&mut bytes,
)
} == 0
{
return Err(bad());
@@ -94,7 +99,15 @@ impl<'a> Suspended<'a> {
/// Creates hidden, with no inherited handles and an explicit environment and
/// current directory. The profile borrow prevents cleanup while this owner
/// exists. This API never resumes extension instructions.
pub fn create(profile: &'a Profile, executable: &Path, mut data: LaunchData) -> Result<Self> {
pub fn create(profile: &'a Profile, executable: &Path, data: LaunchData) -> Result<Self> {
Self::create_inner(profile, executable, data, None)
}
fn create_inner(
profile: &'a Profile,
executable: &Path,
mut data: LaunchData,
io: Option<crate::extension_stdio::ChildIo>,
) -> Result<Self> {
let bad = || HostError::new("EXTENSION_PROCESS_CREATE_FAILED");
if !executable.is_absolute() || data.command_mut().last() != Some(&0) {
return Err(bad());
@@ -106,7 +119,7 @@ impl<'a> Suspended<'a> {
let executable: Vec<_> = executable.into_iter().chain(Some(0)).collect();
let folder = profile.folder()?;
let directory: Vec<u16> = folder.as_os_str().encode_wide().chain(Some(0)).collect();
let mut attributes = Attributes::new()?;
let mut attributes = Attributes::new(if io.is_some() { 2 } else { 1 })?;
let caps = SECURITY_CAPABILITIES {
AppContainerSid: profile.sid(),
Capabilities: std::ptr::null_mut(),
@@ -131,6 +144,29 @@ impl<'a> Suspended<'a> {
let mut startup = STARTUPINFOEXW::default();
startup.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
startup.lpAttributeList = attributes.buffer.as_mut_ptr().cast();
// Keep both the handle array and the owning pipe ends alive across
// CreateProcessW. No arbitrary inheritable Host handle is admitted.
let inherited = io.as_ref().map(|value| value.handles());
if let Some(handles) = &inherited {
if unsafe {
UpdateProcThreadAttribute(
attributes.buffer.as_mut_ptr().cast(),
0,
PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
handles.as_ptr().cast(),
size_of::<[windows_sys::Win32::Foundation::HANDLE; 3]>(),
std::ptr::null_mut(),
std::ptr::null(),
)
} == 0
{
return Err(HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED"));
}
startup.StartupInfo.dwFlags |= STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = handles[0];
startup.StartupInfo.hStdOutput = handles[1];
startup.StartupInfo.hStdError = handles[2];
}
let mut info = PROCESS_INFORMATION::default();
let environment = data.environment().as_ptr();
if unsafe {
@@ -139,7 +175,7 @@ impl<'a> Suspended<'a> {
data.command_mut().as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
0,
i32::from(io.is_some()),
CREATE_SUSPENDED
| CREATE_NO_WINDOW
| EXTENDED_STARTUPINFO_PRESENT
@@ -182,6 +218,19 @@ impl<'a> Suspended<'a> {
value.0._bound_entry = Some(entry);
Ok(value)
}
/// Create instance-specific stdio without exposing an address or trusting a
/// self-reported process/package identity. Host endpoints are never inherited.
#[cfg(feature = "desktop")]
pub fn create_bound_with_stdio(
profile: &'a Profile,
entry: &'a crate::extension_pinned::BoundEntry<'a>,
data: LaunchData,
) -> Result<(Self, crate::extension_stdio::HostIo)> {
let (child, host) = crate::extension_stdio::ChildIo::create()?;
let mut value = Self::create_inner(profile, entry.path(), data, Some(child))?;
value.0._bound_entry = Some(entry);
Ok((value, host))
}
/// # Safety
/// Caller must hold the verified package/entry handles and revalidate the
/// current execution permit, trust, Vault binding, environment declarations,
+214
View File
@@ -0,0 +1,214 @@
//! Per-launch anonymous pipes. Only child ends enter the explicit inheritance
//! list. The runtime owns Host ends and must bound frames and cancel blocked IO.
use crate::workspace::{HostError, Result};
#[cfg(any(feature = "desktop", test))]
use std::os::windows::io::FromRawHandle;
use std::{
fs::File,
os::windows::io::{AsRawHandle, OwnedHandle},
};
use windows_sys::Win32::Foundation::*;
#[cfg(any(feature = "desktop", test))]
use windows_sys::Win32::System::Pipes::CreatePipe;
pub struct HostIo {
pub input: File,
pub output: File,
pub error: File,
}
pub(crate) struct ChildIo {
input: OwnedHandle,
output: OwnedHandle,
error: OwnedHandle,
}
#[cfg(any(feature = "desktop", test))]
fn pair() -> Result<(OwnedHandle, OwnedHandle)> {
let mut read = std::ptr::null_mut();
let mut write = std::ptr::null_mut();
if unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 4096) } == 0 {
return Err(HostError::new("EXTENSION_PIPE_CREATE_FAILED"));
}
Ok(unsafe {
(
OwnedHandle::from_raw_handle(read),
OwnedHandle::from_raw_handle(write),
)
})
}
impl ChildIo {
#[cfg(any(feature = "desktop", test))]
pub(crate) fn create() -> Result<(Self, HostIo)> {
let (input, host_input) = pair()?;
let (host_output, output) = pair()?;
let (host_error, error) = pair()?;
let child = Self {
input,
output,
error,
};
for handle in child.handles() {
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }
== 0
{
return Err(HostError::new("EXTENSION_PIPE_CREATE_FAILED"));
}
}
Ok((
child,
HostIo {
input: host_input.into(),
output: host_output.into(),
error: host_error.into(),
},
))
}
pub(crate) fn handles(&self) -> [HANDLE; 3] {
[
self.input.as_raw_handle(),
self.output.as_raw_handle(),
self.error.as_raw_handle(),
]
}
}
/// NDJSON maximum excludes the line terminator. A protocol/IO error poisons
/// the decoder; the runtime must terminate the instance and close its pipes.
/// This synchronous decoder needs a separate IO cancellation/deadline owner.
pub const MAX_FRAME_BYTES: usize = 2 * 1024 * 1024;
pub struct Frames<R> {
reader: R,
failed: bool,
}
impl<R: std::io::BufRead> Frames<R> {
pub fn new(reader: R) -> Self {
Self {
reader,
failed: false,
}
}
pub fn read(&mut self) -> Result<Option<Vec<u8>>> {
if self.failed {
return Err(HostError::new("EXTENSION_PIPE_CLOSED"));
}
let result = self.read_inner();
if result.is_err() {
self.failed = true;
}
result
}
fn read_inner(&mut self) -> Result<Option<Vec<u8>>> {
let mut frame = Vec::new();
loop {
let available = self
.reader
.fill_buf()
.map_err(|_| HostError::new("EXTENSION_PIPE_READ_FAILED"))?;
if available.is_empty() {
return if frame.is_empty() {
Ok(None)
} else {
Err(HostError::new("EXTENSION_PIPE_TRUNCATED_FRAME"))
};
}
let end = available.iter().position(|b| *b == b'\n');
let count = end.unwrap_or(available.len());
if count > MAX_FRAME_BYTES - frame.len() {
return Err(HostError::new("EXTENSION_BROKER_REQUEST_TOO_LARGE"));
}
frame.extend_from_slice(&available[..count]);
self.reader.consume(count + usize::from(end.is_some()));
if end.is_some() {
if frame.last() == Some(&b'\r') {
frame.pop();
}
if frame.is_empty() {
return Err(HostError::new("EXTENSION_PIPE_EMPTY_FRAME"));
}
return Ok(Some(frame));
}
}
}
}
pub fn write_frame(writer: &mut impl std::io::Write, frame: &[u8]) -> Result<()> {
if frame.is_empty()
|| frame.len() > MAX_FRAME_BYTES
|| frame.contains(&b'\n')
|| frame.contains(&b'\r')
{
return Err(HostError::new("EXTENSION_PIPE_INVALID_FRAME"));
}
writer
.write_all(frame)
.and_then(|_| writer.write_all(b"\n"))
.and_then(|_| writer.flush())
.map_err(|_| HostError::new("EXTENSION_PIPE_WRITE_FAILED"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_child_ends_are_inheritable_and_all_streams_are_distinct() {
let (child, host) = ChildIo::create().unwrap();
let ends = child
.handles()
.into_iter()
.chain([
host.input.as_raw_handle(),
host.output.as_raw_handle(),
host.error.as_raw_handle(),
])
.collect::<Vec<_>>();
for (index, handle) in ends.iter().enumerate() {
let mut flags = 0;
assert_ne!(unsafe { GetHandleInformation(*handle, &mut flags) }, 0);
assert_eq!(flags & HANDLE_FLAG_INHERIT != 0, index < 3);
assert!(!ends[..index].contains(handle));
}
}
#[test]
fn frames_are_bounded_across_fragmentation_and_poison_after_errors() {
use std::io::{BufReader, Cursor};
let mut frames = Frames::new(BufReader::with_capacity(
1,
Cursor::new(b"{\"id\":1}\n{\"id\":2}\r\n"),
));
assert_eq!(frames.read().unwrap().unwrap(), b"{\"id\":1}");
assert_eq!(frames.read().unwrap().unwrap(), b"{\"id\":2}");
assert!(frames.read().unwrap().is_none());
let mut largest = vec![b'x'; MAX_FRAME_BYTES];
largest.push(b'\n');
assert_eq!(
Frames::new(BufReader::with_capacity(127, Cursor::new(&largest)))
.read()
.unwrap()
.unwrap()
.len(),
MAX_FRAME_BYTES
);
largest.insert(0, b'x');
let mut overflow = Frames::new(BufReader::with_capacity(127, Cursor::new(largest)));
assert_eq!(
overflow.read().unwrap_err().code,
"EXTENSION_BROKER_REQUEST_TOO_LARGE"
);
assert_eq!(overflow.read().unwrap_err().code, "EXTENSION_PIPE_CLOSED");
let mut truncated = Frames::new(Cursor::new(b"{}"));
assert_eq!(
truncated.read().unwrap_err().code,
"EXTENSION_PIPE_TRUNCATED_FRAME"
);
assert_eq!(truncated.read().unwrap_err().code, "EXTENSION_PIPE_CLOSED");
assert_eq!(
Frames::new(Cursor::new(b"\n")).read().unwrap_err().code,
"EXTENSION_PIPE_EMPTY_FRAME"
);
let mut output = Vec::new();
for invalid in [&b""[..], &b"{}\n{}"[..], &b"{}\r"[..]] {
assert!(write_frame(&mut output, invalid).is_err());
assert!(output.is_empty());
}
write_frame(&mut output, b"{}").unwrap();
assert_eq!(output, b"{}\n");
}
}
+3
View File
@@ -78,3 +78,6 @@ mod extension_revocation;
#[cfg(all(windows, feature = "desktop"))]
pub mod extension_file_broker;
#[cfg(windows)]
pub mod extension_stdio;