feat(windows): 使用作业对象约束扩展进程组
This commit is contained in:
@@ -312,3 +312,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 任一后续键发生 revision 冲突时,前面已执行的更新全部回滚;确认凭据只在整组成功后一起消耗。此变更替代此前文档中逐键确认可能部分成功的流程。
|
- 任一后续键发生 revision 冲突时,前面已执行的更新全部回滚;确认凭据只在整组成功后一起消耗。此变更替代此前文档中逐键确认可能部分成功的流程。
|
||||||
- 34 项扩展回归、2 项 Host 确认测试、3 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过。日志 `.build/extension-trust-group-tests.log`。验证后续键冲突后重开仍无部分更新,以及失败保留有效凭据、成功全部消耗。
|
- 34 项扩展回归、2 项 Host 确认测试、3 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过。日志 `.build/extension-trust-group-tests.log`。验证后续键冲突后重开仍无部分更新,以及失败保留有效凭据、成功全部消耗。
|
||||||
- 真实桌面确认链路端到端、安装执行、沙箱及总体生产化验收仍未完成。
|
- 真实桌面确认链路端到端、安装执行、沙箱及总体生产化验收仍未完成。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:Windows Job Object 资源容器
|
||||||
|
|
||||||
|
- 新增 extension_job,创建无可继承句柄的 Job,设置组提交内存 512 MiB、活动进程默认 16、关闭最后句柄时杀死进程;不启用 breakaway。CPU 使用 hard cap,按系统活动逻辑处理器数向下取整分配至单核等效份额;任一设置失败不返回可用容器。
|
||||||
|
- assign_suspended 明确要求调用方持有尚未恢复的 CREATE_SUSPENDED 进程,出错须终止它,全部隔离/许可检查后才能 ResumeThread。该模块只有资源约束,尚未接入第三方运行入口,也不具备文件、网络或身份隔离。
|
||||||
|
- 4 项真实 Windows 进程测试与全目标 Clippy -D warnings 通过,日志 `.build/extension-job-tests.log`。三个 ignored 条目是由父测试实际启动的辅助入口。验证进程配额为 1 时第二进程被拒绝且从未恢复、关闭 Job 后进程退出、受管理后代计数归零、显式终止,以及正常 32 MiB 分配成功/600 MiB 分配失败。
|
||||||
|
- 测试初次使用固定等待观察进程计数失败,改为最多 5 秒等待计数收敛后通过;不将单次固定延时当作清理证明。生产参数测试读取 OS 返回的内存、进程和 CPU 配置,但尚未完成长时间 CPU 压力、10 秒超限终止及 scratch 配额测试。
|
||||||
|
- API 依据 Microsoft Job Objects 与 JOBOBJECT_CPU_RATE_CONTROL_INFORMATION 文档: https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects 、 https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_cpu_rate_control_information 。AppContainer、文件/网络 broker、恶意程序矩阵及实际扩展调用仍待完成,不能据此通过 C 项或开启 extensions capability。
|
||||||
|
|||||||
@@ -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_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_System_JobObjects", "Win32_System_Threading", "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 = [] }
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! Windows resource containment only. This is NOT a filesystem/network sandbox.
|
||||||
|
use crate::workspace::{HostError, Result};
|
||||||
|
use std::{
|
||||||
|
mem::size_of,
|
||||||
|
os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle},
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::System::{
|
||||||
|
JobObjects::*,
|
||||||
|
Threading::{GetActiveProcessorCount, ALL_PROCESSOR_GROUPS},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Job {
|
||||||
|
handle: OwnedHandle,
|
||||||
|
}
|
||||||
|
impl Job {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
Self::with_process_limit(16)
|
||||||
|
}
|
||||||
|
fn with_process_limit(processes: u32) -> Result<Self> {
|
||||||
|
let raw = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
||||||
|
if raw.is_null() {
|
||||||
|
return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"));
|
||||||
|
}
|
||||||
|
let job = Self {
|
||||||
|
handle: unsafe { OwnedHandle::from_raw_handle(raw) },
|
||||||
|
};
|
||||||
|
let limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||||
|
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||||
|
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||||
|
| JOB_OBJECT_LIMIT_ACTIVE_PROCESS
|
||||||
|
| JOB_OBJECT_LIMIT_JOB_MEMORY,
|
||||||
|
ActiveProcessLimit: processes,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
JobMemoryLimit: 512 * 1024 * 1024,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
job.set(JobObjectExtendedLimitInformation, &limits)?;
|
||||||
|
let processors = unsafe { GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) };
|
||||||
|
if processors == 0 {
|
||||||
|
return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"));
|
||||||
|
}
|
||||||
|
let cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION {
|
||||||
|
ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP,
|
||||||
|
Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 {
|
||||||
|
CpuRate: (10000 / processors).max(1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
job.set(JobObjectCpuRateControlInformation, &cpu)?;
|
||||||
|
Ok(job)
|
||||||
|
}
|
||||||
|
fn set<T>(&self, class: JOBOBJECTINFOCLASS, value: &T) -> Result<()> {
|
||||||
|
if unsafe {
|
||||||
|
SetInformationJobObject(
|
||||||
|
self.handle.as_raw_handle(),
|
||||||
|
class,
|
||||||
|
(value as *const T).cast(),
|
||||||
|
size_of::<T>() as u32,
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// Attach before any extension instruction executes. No breakaway flags are enabled.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Caller must own an unresumed CREATE_SUSPENDED process and terminate it on
|
||||||
|
/// any error. Resume only after all AppContainer/handle/permission checks pass.
|
||||||
|
pub unsafe fn assign_suspended(&self, process: BorrowedHandle<'_>) -> Result<()> {
|
||||||
|
if unsafe { AssignProcessToJobObject(self.handle.as_raw_handle(), process.as_raw_handle()) }
|
||||||
|
== 0
|
||||||
|
{
|
||||||
|
return Err(HostError::new("EXTENSION_RESOURCE_ASSIGN_FAILED"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn terminate(&self) -> Result<()> {
|
||||||
|
if unsafe { TerminateJobObject(self.handle.as_raw_handle(), 1) } == 0 {
|
||||||
|
return Err(HostError::new("EXTENSION_RESOURCE_TERMINATE_FAILED"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn active_processes(&self) -> Result<u32> {
|
||||||
|
let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default();
|
||||||
|
if unsafe {
|
||||||
|
QueryInformationJobObject(
|
||||||
|
self.handle.as_raw_handle(),
|
||||||
|
JobObjectBasicAccountingInformation,
|
||||||
|
(&mut accounting as *mut JOBOBJECT_BASIC_ACCOUNTING_INFORMATION).cast(),
|
||||||
|
size_of::<JOBOBJECT_BASIC_ACCOUNTING_INFORMATION>() as u32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(HostError::new("EXTENSION_RESOURCE_QUERY_FAILED"));
|
||||||
|
}
|
||||||
|
Ok(accounting.ActiveProcesses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::{
|
||||||
|
os::windows::{ffi::OsStrExt, io::AsHandle},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::{Foundation::WAIT_OBJECT_0, System::Threading::*};
|
||||||
|
struct Worker {
|
||||||
|
process: OwnedHandle,
|
||||||
|
thread: OwnedHandle,
|
||||||
|
}
|
||||||
|
impl Drop for Worker {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
TerminateProcess(self.process.as_raw_handle(), 1);
|
||||||
|
WaitForSingleObject(self.process.as_raw_handle(), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn worker() -> Worker {
|
||||||
|
worker_named("worker_wait")
|
||||||
|
}
|
||||||
|
fn worker_named(name: &str) -> Worker {
|
||||||
|
let exe = std::env::current_exe().unwrap();
|
||||||
|
let app: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||||
|
let command = format!(
|
||||||
|
"\"{}\" --ignored --exact extension_job::tests::{name} --nocapture",
|
||||||
|
exe.display()
|
||||||
|
);
|
||||||
|
let mut command: Vec<u16> = command.encode_utf16().chain(Some(0)).collect();
|
||||||
|
let startup = STARTUPINFOW {
|
||||||
|
cb: size_of::<STARTUPINFOW>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut info = PROCESS_INFORMATION::default();
|
||||||
|
let empty_environment = [0u16, 0];
|
||||||
|
assert_ne!(
|
||||||
|
unsafe {
|
||||||
|
CreateProcessW(
|
||||||
|
app.as_ptr(),
|
||||||
|
command.as_mut_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
std::ptr::null(),
|
||||||
|
0,
|
||||||
|
CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT,
|
||||||
|
empty_environment.as_ptr().cast(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&startup,
|
||||||
|
&mut info,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
Worker {
|
||||||
|
process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) },
|
||||||
|
thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
#[ignore = "helper process launched suspended by resource containment test"]
|
||||||
|
fn worker_wait() {
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
#[ignore = "helper process creates a managed descendant for the parent test"]
|
||||||
|
fn worker_tree() {
|
||||||
|
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args([
|
||||||
|
"--ignored",
|
||||||
|
"--exact",
|
||||||
|
"extension_job::tests::worker_wait",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
child.wait().unwrap();
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
#[ignore = "helper probes allocations inside the parent's limited job"]
|
||||||
|
fn worker_memory() {
|
||||||
|
let mut normal = Vec::<u8>::new();
|
||||||
|
normal.try_reserve_exact(32 * 1024 * 1024).unwrap();
|
||||||
|
normal.resize(32 * 1024 * 1024, 1);
|
||||||
|
assert_eq!(normal[normal.len() - 1], 1);
|
||||||
|
let mut excessive = Vec::<u8>::new();
|
||||||
|
assert!(excessive.try_reserve_exact(600 * 1024 * 1024).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn actual_allocation_above_job_memory_budget_is_refused() {
|
||||||
|
let job = Job::new().unwrap();
|
||||||
|
let child = worker_named("worker_memory");
|
||||||
|
unsafe {
|
||||||
|
job.assign_suspended(child.process.as_handle()).unwrap();
|
||||||
|
assert_ne!(ResumeThread(child.thread.as_raw_handle()), u32::MAX);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { WaitForSingleObject(child.process.as_raw_handle(), 10000) },
|
||||||
|
WAIT_OBJECT_0
|
||||||
|
);
|
||||||
|
let mut code = 1;
|
||||||
|
assert_ne!(
|
||||||
|
unsafe { GetExitCodeProcess(child.process.as_raw_handle(), &mut code) },
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn managed_descendants_terminate_with_their_job() {
|
||||||
|
let job = Job::new().unwrap();
|
||||||
|
let root = worker_named("worker_tree");
|
||||||
|
unsafe {
|
||||||
|
job.assign_suspended(root.process.as_handle()).unwrap();
|
||||||
|
assert_ne!(ResumeThread(root.thread.as_raw_handle()), u32::MAX);
|
||||||
|
}
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while job.active_processes().unwrap() < 2 && std::time::Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert_eq!(job.active_processes().unwrap(), 2);
|
||||||
|
job.terminate().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { WaitForSingleObject(root.process.as_raw_handle(), 5000) },
|
||||||
|
WAIT_OBJECT_0
|
||||||
|
);
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while job.active_processes().unwrap() != 0 && std::time::Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert_eq!(job.active_processes().unwrap(), 0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn actual_suspended_process_assignment_limits_and_close_cleanup() {
|
||||||
|
let job = Job::with_process_limit(1).unwrap();
|
||||||
|
let first = worker();
|
||||||
|
unsafe {
|
||||||
|
job.assign_suspended(first.process.as_handle()).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(job.active_processes().unwrap(), 1);
|
||||||
|
let second = worker();
|
||||||
|
assert!(unsafe { job.assign_suspended(second.process.as_handle()) }.is_err());
|
||||||
|
// The second process has never been resumed, even after assignment failure.
|
||||||
|
drop(second);
|
||||||
|
assert_ne!(
|
||||||
|
unsafe { ResumeThread(first.thread.as_raw_handle()) },
|
||||||
|
u32::MAX
|
||||||
|
);
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while job.active_processes().unwrap() != 1 && std::time::Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert_eq!(job.active_processes().unwrap(), 1);
|
||||||
|
drop(job);
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { WaitForSingleObject(first.process.as_raw_handle(), 5000) },
|
||||||
|
WAIT_OBJECT_0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn production_limits_are_configured_and_explicit_termination_reaps_process() {
|
||||||
|
let job = Job::new().unwrap();
|
||||||
|
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||||
|
assert_ne!(
|
||||||
|
unsafe {
|
||||||
|
QueryInformationJobObject(
|
||||||
|
job.handle.as_raw_handle(),
|
||||||
|
JobObjectExtendedLimitInformation,
|
||||||
|
(&mut info as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
|
||||||
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(info.JobMemoryLimit, 512 * 1024 * 1024);
|
||||||
|
assert_eq!(info.BasicLimitInformation.ActiveProcessLimit, 16);
|
||||||
|
assert_eq!(
|
||||||
|
info.BasicLimitInformation.LimitFlags
|
||||||
|
& (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let mut cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION::default();
|
||||||
|
assert_ne!(
|
||||||
|
unsafe {
|
||||||
|
QueryInformationJobObject(
|
||||||
|
job.handle.as_raw_handle(),
|
||||||
|
JobObjectCpuRateControlInformation,
|
||||||
|
(&mut cpu as *mut JOBOBJECT_CPU_RATE_CONTROL_INFORMATION).cast(),
|
||||||
|
size_of::<JOBOBJECT_CPU_RATE_CONTROL_INFORMATION>() as u32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cpu.ControlFlags,
|
||||||
|
JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP
|
||||||
|
);
|
||||||
|
let child = worker();
|
||||||
|
unsafe {
|
||||||
|
job.assign_suspended(child.process.as_handle()).unwrap();
|
||||||
|
}
|
||||||
|
job.terminate().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { WaitForSingleObject(child.process.as_raw_handle(), 5000) },
|
||||||
|
WAIT_OBJECT_0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,3 +51,6 @@ pub mod extension_config;
|
|||||||
|
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod extension_trust;
|
pub mod extension_trust;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod extension_job;
|
||||||
|
|||||||
Reference in New Issue
Block a user