feat(sandbox): 添加明确的标准输入输出通道与有界帧传输
This commit is contained in:
@@ -459,3 +459,14 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 新增真实临时 Workspace / Stronghold 测试,覆盖正常读写、CAS、同 ID 内容冲突、跨包隔离、重启重放、权限/伪造字段/私有路径拒绝、帧/正文/速率限制、硬链接与异 Vault 拒绝、凭据锁定撤销。另有 Workspace 故障注入测试在提交前拒绝,确认重启正文与 outbox 不变,同 ID 原内容可以重试并只增加一次 revision/outbox。
|
- 新增真实临时 Workspace / Stronghold 测试,覆盖正常读写、CAS、同 ID 内容冲突、跨包隔离、重启重放、权限/伪造字段/私有路径拒绝、帧/正文/速率限制、硬链接与异 Vault 拒绝、凭据锁定撤销。另有 Workspace 故障注入测试在提交前拒绝,确认重启正文与 outbox 不变,同 ID 原内容可以重试并只增加一次 revision/outbox。
|
||||||
- 当前仍是内部策略适配器,未开放第三方执行。写入底层尚使用字符串路径,需完成全程句柄约束;原子撤权与最终提交的严格排序、真实管道身份认证、Vault 根目录替换、网络 broker 及完整攻击矩阵也尚未完成,不据此判定 C-02 / C-04 或整体生产化通过。
|
- 当前仍是内部策略适配器,未开放第三方执行。写入底层尚使用字符串路径,需完成全程句柄约束;原子撤权与最终提交的严格排序、真实管道身份认证、Vault 根目录替换、网络 broker 及完整攻击矩阵也尚未完成,不据此判定 C-02 / C-04 或整体生产化通过。
|
||||||
- 本轮完整 Rust desktop all-targets 回归 115 项通过、6 项 ignored;其中三个 Job 辅助入口和一个上传故障辅助入口由父测试实际驱动,另外两项是需显式执行的 60 秒期限验收与打包 Core 20 次冷启动,本轮未重跑。全目标 Clippy -D warnings 通过。日志 `.build/extension-file-broker-full-tests.log`、`.build/extension-file-broker-clippy.log`。未修改前端/Python 实现,本轮没有把此前测试结果冒充重新执行结果。
|
- 本轮完整 Rust desktop all-targets 回归 115 项通过、6 项 ignored;其中三个 Job 辅助入口和一个上传故障辅助入口由父测试实际驱动,另外两项是需显式执行的 60 秒期限验收与打包 Core 20 次冷启动,本轮未重跑。全目标 Clippy -D warnings 通过。日志 `.build/extension-file-broker-full-tests.log`、`.build/extension-file-broker-clippy.log`。未修改前端/Python 实现,本轮没有把此前测试结果冒充重新执行结果。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:沙箱实例标准管道与实际文件 RPC 探针
|
||||||
|
|
||||||
|
- 新增 extension_stdio 的每次启动专用匿名管道;Host 端不继承,子端只有 stdin/stdout/stderr 三个句柄。扩展进程创建同时设置 SECURITY_CAPABILITIES 与 PROC_THREAD_ATTRIBUTE_HANDLE_LIST,STARTF_USESTDHANDLES 指向这三个子端;所有者保持到 CreateProcessW 返回,失败自动关闭,Host 不保留多余子端阻塞 EOF。
|
||||||
|
- PreparedLaunch 新增带 stdio 的挂起创建路径,沿用创建前后租约检查、BoundEntry 全生命周期持有、AppContainer 身份核验和 Job 先绑定后恢复。返回 Host 管道端供实例运行线程持有,未引入共享监听地址、请求自报 PID 或包身份。无 stdio 的受控探针路径继续不继承任何句柄。
|
||||||
|
- 新增有界 NDJSON 解码器:每帧最多 2 MiB,在扩展缓冲前检查;拒绝超长、空帧与 EOF 前未结束的帧,协议/读取错误后解码器不再继续解析。编码拒绝未转义换行与超限帧。该同步组件不自行解决阻塞读取/写入,需要后续 IO 取消所有者与工具期限控制;stderr 持续排空/限额也仍待运行编排接入。
|
||||||
|
- 真实 AppContainer 原生探针经已签发参数/环境与解锁会话启动,通过 stdout 发出 notes.read,由绑定当前 Vault 和权限的文件 broker 处理,经 stdin 返回正文;子进程核对正文后正常退出。测试同时确认独立 stderr、关闭 stdin 产生 EOF、正文未改动,以及 Host 中另一个显式可继承的事件句柄未进入该子进程。仅使用隔离测试 Vault/虚构凭据。
|
||||||
|
- 管道标志测试确认三个 Host 端全部非继承、三个子端继承且六个句柄互异;帧测试覆盖单字节分片、多帧、CRLF、恰好上限、超一字节、截断、错误后封闭及写入拒绝。57 项扩展回归通过、4 项 ignored 为既有三个父测试驱动辅助入口与显式 60 秒验收。desktop 全目标及非 desktop 库 Clippy -D warnings 均通过;首次非 desktop 检查指出两处桌面专用构造函数未限制编译条件,已修正。日志 `.build/extension-stdio-tests.log`、`.build/extension-stdio-clippy.log`、`.build/extension-stdio-core-clippy.log`。
|
||||||
|
- API 依据 [Microsoft handle inheritance](https://learn.microsoft.com/en-us/windows/win32/procthread/inheritance)。明确句柄清单约束本次扩展创建,但 Host 内其他并发启动若使用不带清单的全量句柄继承,仍可能继承这段短暂窗口内的子端;正式启用前须审计并统一所有创建路径或采用隔离创建代理,不能仅凭本测试声明该并发风险消失。
|
||||||
|
- 这增加真实沙箱至文件 broker 的通信证据,尚未接入用户安装实例注册、JSON-RPC/MCP 派发、活跃安装/信任复核、IO 超时/背压和完整句柄安全写入。第三方执行仍禁用,C-02/C-04 及完整生产化保持未通过状态。
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ cap-fs-ext = "4.0.2"
|
|||||||
jsonschema = { version = "0.55", default-features = false }
|
jsonschema = { version = "0.55", default-features = false }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
|
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", optional = true , features = [] }
|
tauri-build = { version = "2", optional = true , features = [] }
|
||||||
|
|||||||
@@ -774,6 +774,90 @@ mod tests {
|
|||||||
Some(0)
|
Some(0)
|
||||||
);
|
);
|
||||||
drop(running);
|
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 [
|
for cause in [
|
||||||
"before_create",
|
"before_create",
|
||||||
"before_resume",
|
"before_resume",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
use windows_sys::Win32::Storage::FileSystem::*;
|
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;
|
pub const MAX_NOTE_BYTES: usize = 1024 * 1024;
|
||||||
const REQUESTS_PER_SECOND: u32 = 32;
|
const REQUESTS_PER_SECOND: u32 = 32;
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -50,6 +50,30 @@ impl PreparedLaunch {
|
|||||||
lease: self.lease,
|
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> {
|
impl<'a> LeasedSuspended<'a> {
|
||||||
/// # Safety
|
/// # Safety
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ struct Attributes {
|
|||||||
initialized: bool,
|
initialized: bool,
|
||||||
}
|
}
|
||||||
impl Attributes {
|
impl Attributes {
|
||||||
fn new() -> Result<Self> {
|
fn new(count: u32) -> Result<Self> {
|
||||||
let bad = || HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED");
|
let bad = || HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED");
|
||||||
let mut bytes = 0;
|
let mut bytes = 0;
|
||||||
unsafe {
|
unsafe {
|
||||||
InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut bytes);
|
InitializeProcThreadAttributeList(std::ptr::null_mut(), count, 0, &mut bytes);
|
||||||
}
|
}
|
||||||
if bytes == 0 || bytes > 65536 {
|
if bytes == 0 || bytes > 65536 {
|
||||||
return Err(bad());
|
return Err(bad());
|
||||||
@@ -40,7 +40,12 @@ impl Attributes {
|
|||||||
initialized: false,
|
initialized: false,
|
||||||
};
|
};
|
||||||
if unsafe {
|
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
|
} == 0
|
||||||
{
|
{
|
||||||
return Err(bad());
|
return Err(bad());
|
||||||
@@ -94,7 +99,15 @@ impl<'a> Suspended<'a> {
|
|||||||
/// Creates hidden, with no inherited handles and an explicit environment and
|
/// Creates hidden, with no inherited handles and an explicit environment and
|
||||||
/// current directory. The profile borrow prevents cleanup while this owner
|
/// current directory. The profile borrow prevents cleanup while this owner
|
||||||
/// exists. This API never resumes extension instructions.
|
/// 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");
|
let bad = || HostError::new("EXTENSION_PROCESS_CREATE_FAILED");
|
||||||
if !executable.is_absolute() || data.command_mut().last() != Some(&0) {
|
if !executable.is_absolute() || data.command_mut().last() != Some(&0) {
|
||||||
return Err(bad());
|
return Err(bad());
|
||||||
@@ -106,7 +119,7 @@ impl<'a> Suspended<'a> {
|
|||||||
let executable: Vec<_> = executable.into_iter().chain(Some(0)).collect();
|
let executable: Vec<_> = executable.into_iter().chain(Some(0)).collect();
|
||||||
let folder = profile.folder()?;
|
let folder = profile.folder()?;
|
||||||
let directory: Vec<u16> = folder.as_os_str().encode_wide().chain(Some(0)).collect();
|
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 {
|
let caps = SECURITY_CAPABILITIES {
|
||||||
AppContainerSid: profile.sid(),
|
AppContainerSid: profile.sid(),
|
||||||
Capabilities: std::ptr::null_mut(),
|
Capabilities: std::ptr::null_mut(),
|
||||||
@@ -131,6 +144,29 @@ impl<'a> Suspended<'a> {
|
|||||||
let mut startup = STARTUPINFOEXW::default();
|
let mut startup = STARTUPINFOEXW::default();
|
||||||
startup.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
|
startup.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
|
||||||
startup.lpAttributeList = attributes.buffer.as_mut_ptr().cast();
|
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 mut info = PROCESS_INFORMATION::default();
|
||||||
let environment = data.environment().as_ptr();
|
let environment = data.environment().as_ptr();
|
||||||
if unsafe {
|
if unsafe {
|
||||||
@@ -139,7 +175,7 @@ impl<'a> Suspended<'a> {
|
|||||||
data.command_mut().as_mut_ptr(),
|
data.command_mut().as_mut_ptr(),
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
0,
|
i32::from(io.is_some()),
|
||||||
CREATE_SUSPENDED
|
CREATE_SUSPENDED
|
||||||
| CREATE_NO_WINDOW
|
| CREATE_NO_WINDOW
|
||||||
| EXTENDED_STARTUPINFO_PRESENT
|
| EXTENDED_STARTUPINFO_PRESENT
|
||||||
@@ -182,6 +218,19 @@ impl<'a> Suspended<'a> {
|
|||||||
value.0._bound_entry = Some(entry);
|
value.0._bound_entry = Some(entry);
|
||||||
Ok(value)
|
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
|
/// # Safety
|
||||||
/// Caller must hold the verified package/entry handles and revalidate the
|
/// Caller must hold the verified package/entry handles and revalidate the
|
||||||
/// current execution permit, trust, Vault binding, environment declarations,
|
/// current execution permit, trust, Vault binding, environment declarations,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,3 +78,6 @@ mod extension_revocation;
|
|||||||
|
|
||||||
#[cfg(all(windows, feature = "desktop"))]
|
#[cfg(all(windows, feature = "desktop"))]
|
||||||
pub mod extension_file_broker;
|
pub mod extension_file_broker;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod extension_stdio;
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ use std::net::{SocketAddr, TcpStream, UdpSocket};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<_> = std::env::args().collect();
|
let args: Vec<_> = std::env::args().collect();
|
||||||
|
if args.get(1).is_some_and(|s| s == "file_rpc") {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
#[link(name = "kernel32")]
|
||||||
|
extern "system" { fn GetHandleInformation(handle: *mut std::ffi::c_void, flags: *mut u32) -> i32; }
|
||||||
|
let sentinel: usize = args[2].parse().unwrap();
|
||||||
|
let mut flags = 0;
|
||||||
|
if unsafe { GetHandleInformation(sentinel as *mut _, &mut flags) } != 0 { std::process::exit(85); }
|
||||||
|
println!("{{\"method\":\"notes.read\",\"path\":\"fixture.md\"}}");
|
||||||
|
std::io::stdout().flush().unwrap();
|
||||||
|
let mut response = String::new();
|
||||||
|
std::io::stdin().take(4096).read_to_string(&mut response).unwrap();
|
||||||
|
if !response.contains("\"content\":\"from host broker\"") { std::process::exit(86); }
|
||||||
|
println!("{{\"ok\":true}}");
|
||||||
|
eprintln!("fixture diagnostic");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if args.get(1).is_some_and(|s| s == "wait_tree") {
|
if args.get(1).is_some_and(|s| s == "wait_tree") {
|
||||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
.arg("wait")
|
.arg("wait")
|
||||||
|
|||||||
Reference in New Issue
Block a user