feat(sandbox): 管理挂起的 AppContainer 进程生命周期
This commit is contained in:
@@ -359,3 +359,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- 真实 AppContainer 探针使用该构造器启动,比较完整 argv 和完整环境变量集合:空串、空格、中文/emoji、引号/反斜杠、换行及 &|%PATH% 均原样收到,环境恰好为四个基础变量加 CUSTOM,未继承 PATH 等 Host 环境。测试也保留容器 SID/零能力/先挂起后入 Job 的核对。
|
||||
- 45 项扩展回归通过、3 项 Job 辅助入口 ignored(由父测试实际调用),全目标 Clippy -D warnings 通过。日志 `.build/extension-launch-regression-tests.log`、`.build/extension-launch-clippy.log`。API 依据 [Microsoft CRT 参数解析](https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170) 和 [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw)。尚未覆盖所有第三方运行时的自定义命令行解析。
|
||||
- 当前仍未开放第三方执行:正式启动器的许可/包句柄绑定、生产环境凭据解析、scratch 限额、broker 与完整端到端验收继续实施,不能据本轮参数测试认定生产化完成。
|
||||
|
||||
|
||||
## 增量:Windows 挂起进程所有权与恢复边界
|
||||
|
||||
- 新增 extension_process:使用零能力 SECURITY_CAPABILITIES、明确环境、容器自身工作目录、不可继承句柄和隐藏窗口创建挂起进程,先加入默认资源 Job,再核验 TokenIsAppContainer、精确 SID 和零 TokenCapabilities。属性列表、原生句柄及 Job 均有所有权清理;创建、入 Job 或身份核验失败不返回可恢复进程。
|
||||
- Suspended 与 Running 分开,resume 消耗挂起对象,要求 ResumeThread 返回预期挂起计数 1;不能经同一对象重复恢复。Profile 借用延续到进程对象释放,防止 Host 提前删除配置。Running 提供最多 60 秒的有界等待和整组主动终止;对象释放也终止 Job,关闭句柄并等待根进程退出。
|
||||
- resume 是带明确安全前置条件的底层接口,尚不替代执行许可、包/入口句柄、在线信任、Vault、broker 和完整资源策略核验。上层未满足全部条件时仍必须拒绝运行。未新增 renderer 启动命令,也未开启 extensions capability。
|
||||
- 原生参数/环境探针、IPv4/IPv6 环回探针已切换到此正式进程模块执行,相关行为回归通过。新增实际 Windows 测试验证不匹配 SID 拒绝、丢弃未恢复进程后句柄进入终止状态、入口不存在与损坏终止符失败不消耗 Profile。运行中的等待探针验证零时限返回未完成、超过 60 秒的等待请求拒绝、主动终止后五秒内观察到退出。
|
||||
- 47 项扩展回归通过,3 项 ignored 为由父测试驱动的 Job 辅助入口;全目标 Clippy -D warnings 通过。日志 `.build/extension-process-regression-tests.log`、`.build/extension-process-clippy.log`。
|
||||
- 该模块只收拢已验证的原生生命周期,不构成完整生产扩展启动器。许可和包句柄原子绑定、撤销停止、生产 broker、scratch 配额、工具总期限及崩溃后的配置清理仍未完成,整体生产化继续实施。
|
||||
|
||||
@@ -327,6 +327,16 @@ mod tests {
|
||||
command: Option<String>,
|
||||
mut data: Option<crate::extension_launch_data::LaunchData>,
|
||||
) -> Option<u32> {
|
||||
if let Some(data) = data.take() {
|
||||
let suspended =
|
||||
crate::extension_process::Suspended::create(profile, executable, data).unwrap();
|
||||
// Controlled test fixture only; no user extension is authorized here.
|
||||
let running = unsafe { suspended.resume().unwrap() };
|
||||
let result = running.wait(std::time::Duration::from_secs(10)).unwrap();
|
||||
assert!(result.is_some());
|
||||
running.terminate().unwrap();
|
||||
return result;
|
||||
}
|
||||
let resume = command.is_some() || data.is_some();
|
||||
let mut attributes = Attributes::new();
|
||||
let caps = SECURITY_CAPABILITIES {
|
||||
@@ -677,6 +687,32 @@ mod tests {
|
||||
checked_executable_data(&profile, &executable, None, Some(data)),
|
||||
Some(0)
|
||||
);
|
||||
let data = crate::extension_launch_data::LaunchData::new(
|
||||
&executable,
|
||||
&["wait".to_owned()],
|
||||
&system,
|
||||
&folder,
|
||||
&folder.join("Temp"),
|
||||
&std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let suspended =
|
||||
crate::extension_process::Suspended::create(&profile, &executable, data).unwrap();
|
||||
let running = unsafe { suspended.resume().unwrap() };
|
||||
assert_eq!(running.wait(std::time::Duration::ZERO).unwrap(), None);
|
||||
assert_eq!(
|
||||
running
|
||||
.wait(std::time::Duration::from_millis(60001))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"EXTENSION_PROCESS_WAIT_INVALID"
|
||||
);
|
||||
running.terminate().unwrap();
|
||||
assert!(running
|
||||
.wait(std::time::Duration::from_secs(5))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
drop(running);
|
||||
for ip in ["127.0.0.1:0", "[::1]:0"] {
|
||||
let tcp = TcpListener::bind(ip).unwrap();
|
||||
let udp = UdpSocket::bind(ip).unwrap();
|
||||
@@ -702,11 +738,19 @@ mod tests {
|
||||
assert_eq!(&bytes[..5], b"probe");
|
||||
udp.set_nonblocking(true).unwrap();
|
||||
}
|
||||
let command = format!("\"{}\" {mode} {address}", executable.display());
|
||||
let data = crate::extension_launch_data::LaunchData::new(
|
||||
&executable,
|
||||
&[mode.to_owned(), address.clone()],
|
||||
&system,
|
||||
&folder,
|
||||
&folder.join("Temp"),
|
||||
&std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
// Loopback isolation can silently drop packets. TCP must
|
||||
// explicitly report denial or timeout; UDP send may succeed,
|
||||
// but no datagram may reach the controlled listener below.
|
||||
let exit = checked_executable(&profile, &executable, Some(command));
|
||||
let exit = checked_executable_data(&profile, &executable, None, Some(data));
|
||||
eprintln!("container network probe {mode} {address}: {exit:?}");
|
||||
if mode == "tcp" {
|
||||
assert!(matches!(exit, Some(77 | 80)), "{mode} {address}: {exit:?}");
|
||||
|
||||
@@ -143,7 +143,7 @@ impl LaunchData {
|
||||
result.environment.push(0);
|
||||
Ok(result)
|
||||
}
|
||||
pub fn command_mut(&mut self) -> &mut [u16] {
|
||||
pub(crate) fn command_mut(&mut self) -> &mut [u16] {
|
||||
&mut self.command
|
||||
}
|
||||
/// Pass with CREATE_UNICODE_ENVIRONMENT; never substitute a null pointer.
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Windows process ownership primitive. A suspended process is not execution
|
||||
//! authorization; the extension runtime must complete its checks before resume.
|
||||
use crate::{
|
||||
extension_container::Profile,
|
||||
extension_job::Job,
|
||||
extension_launch_data::LaunchData,
|
||||
workspace::{HostError, Result},
|
||||
};
|
||||
use std::{
|
||||
mem::size_of,
|
||||
os::windows::{
|
||||
ffi::OsStrExt,
|
||||
io::{AsHandle, AsRawHandle, FromRawHandle, OwnedHandle},
|
||||
},
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT},
|
||||
Security::*,
|
||||
System::Threading::*,
|
||||
};
|
||||
|
||||
struct Attributes {
|
||||
buffer: Vec<usize>,
|
||||
initialized: bool,
|
||||
}
|
||||
impl Attributes {
|
||||
fn new() -> Result<Self> {
|
||||
let bad = || HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED");
|
||||
let mut bytes = 0;
|
||||
unsafe {
|
||||
InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut bytes);
|
||||
}
|
||||
if bytes == 0 || bytes > 65536 {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut value = Self {
|
||||
buffer: vec![0; bytes.div_ceil(size_of::<usize>())],
|
||||
initialized: false,
|
||||
};
|
||||
if unsafe {
|
||||
InitializeProcThreadAttributeList(value.buffer.as_mut_ptr().cast(), 1, 0, &mut bytes)
|
||||
} == 0
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
value.initialized = true;
|
||||
// The attribute stores a pointer to SECURITY_CAPABILITIES. The caller
|
||||
// updates it with storage that remains alive until CreateProcessW.
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
impl Drop for Attributes {
|
||||
fn drop(&mut self) {
|
||||
if self.initialized {
|
||||
unsafe {
|
||||
DeleteProcThreadAttributeList(self.buffer.as_mut_ptr().cast());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Handles {
|
||||
process: OwnedHandle,
|
||||
thread: OwnedHandle,
|
||||
}
|
||||
impl Drop for Handles {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
TerminateProcess(self.process.as_raw_handle(), 1);
|
||||
WaitForSingleObject(self.process.as_raw_handle(), 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Process<'a> {
|
||||
handles: Handles,
|
||||
job: Job,
|
||||
_profile: &'a Profile,
|
||||
}
|
||||
impl Drop for Process<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.job.terminate();
|
||||
}
|
||||
}
|
||||
pub struct Suspended<'a>(Process<'a>);
|
||||
pub struct Running<'a>(Process<'a>);
|
||||
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> {
|
||||
let bad = || HostError::new("EXTENSION_PROCESS_CREATE_FAILED");
|
||||
if !executable.is_absolute() || data.command_mut().last() != Some(&0) {
|
||||
return Err(bad());
|
||||
}
|
||||
let executable: Vec<u16> = executable.as_os_str().encode_wide().collect();
|
||||
if executable.is_empty() || executable.len() > 32766 || executable.contains(&0) {
|
||||
return Err(bad());
|
||||
}
|
||||
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 caps = SECURITY_CAPABILITIES {
|
||||
AppContainerSid: profile.sid(),
|
||||
Capabilities: std::ptr::null_mut(),
|
||||
CapabilityCount: 0,
|
||||
Reserved: 0,
|
||||
};
|
||||
if unsafe {
|
||||
UpdateProcThreadAttribute(
|
||||
attributes.buffer.as_mut_ptr().cast(),
|
||||
0,
|
||||
PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize,
|
||||
(&caps as *const SECURITY_CAPABILITIES).cast(),
|
||||
size_of::<SECURITY_CAPABILITIES>(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED"));
|
||||
}
|
||||
let job = Job::new()?;
|
||||
let mut startup = STARTUPINFOEXW::default();
|
||||
startup.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
|
||||
startup.lpAttributeList = attributes.buffer.as_mut_ptr().cast();
|
||||
let mut info = PROCESS_INFORMATION::default();
|
||||
let environment = data.environment().as_ptr();
|
||||
if unsafe {
|
||||
CreateProcessW(
|
||||
executable.as_ptr(),
|
||||
data.command_mut().as_mut_ptr(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
0,
|
||||
CREATE_SUSPENDED
|
||||
| CREATE_NO_WINDOW
|
||||
| EXTENDED_STARTUPINFO_PRESENT
|
||||
| CREATE_UNICODE_ENVIRONMENT,
|
||||
environment.cast(),
|
||||
directory.as_ptr(),
|
||||
&startup.StartupInfo,
|
||||
&mut info,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let handles = Handles {
|
||||
process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) },
|
||||
thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) },
|
||||
};
|
||||
unsafe {
|
||||
job.assign_suspended(handles.process.as_handle())?;
|
||||
}
|
||||
let process = Process {
|
||||
handles,
|
||||
job,
|
||||
_profile: profile,
|
||||
};
|
||||
verify_identity(&process.handles, profile)?;
|
||||
Ok(Self(process))
|
||||
}
|
||||
/// # Safety
|
||||
/// Caller must hold the verified package/entry handles and revalidate the
|
||||
/// current execution permit, trust, Vault binding, environment declarations,
|
||||
/// broker and all resource policy requirements immediately before this call.
|
||||
/// None of those authorization checks is supplied by this low-level module.
|
||||
pub unsafe fn resume(self) -> Result<Running<'a>> {
|
||||
if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 {
|
||||
return Err(HostError::new("EXTENSION_PROCESS_RESUME_FAILED"));
|
||||
}
|
||||
Ok(Running(self.0))
|
||||
}
|
||||
}
|
||||
impl Running<'_> {
|
||||
/// A bounded observation only. The runtime must enforce the tool deadline.
|
||||
pub fn wait(&self, timeout: Duration) -> Result<Option<u32>> {
|
||||
let milliseconds = u32::try_from(timeout.as_millis())
|
||||
.ok()
|
||||
.filter(|n| *n <= 60000)
|
||||
.ok_or_else(|| HostError::new("EXTENSION_PROCESS_WAIT_INVALID"))?;
|
||||
match unsafe { WaitForSingleObject(self.0.handles.process.as_raw_handle(), milliseconds) } {
|
||||
WAIT_TIMEOUT => Ok(None),
|
||||
WAIT_OBJECT_0 => {
|
||||
let mut code = 0;
|
||||
if unsafe { GetExitCodeProcess(self.0.handles.process.as_raw_handle(), &mut code) }
|
||||
== 0
|
||||
{
|
||||
return Err(HostError::new("EXTENSION_PROCESS_QUERY_FAILED"));
|
||||
}
|
||||
Ok(Some(code))
|
||||
}
|
||||
_ => Err(HostError::new("EXTENSION_PROCESS_WAIT_FAILED")),
|
||||
}
|
||||
}
|
||||
/// Terminates the entire managed group, including descendants.
|
||||
pub fn terminate(&self) -> Result<()> {
|
||||
self.0.job.terminate()
|
||||
}
|
||||
}
|
||||
fn verify_identity(handles: &Handles, profile: &Profile) -> Result<()> {
|
||||
let bad = || HostError::new("EXTENSION_PROCESS_IDENTITY_INVALID");
|
||||
let mut token = std::ptr::null_mut();
|
||||
if unsafe { OpenProcessToken(handles.process.as_raw_handle(), TOKEN_QUERY, &mut token) } == 0 {
|
||||
return Err(bad());
|
||||
}
|
||||
let token = unsafe { OwnedHandle::from_raw_handle(token) };
|
||||
let mut contained = 0u32;
|
||||
let mut length = 0;
|
||||
if unsafe {
|
||||
GetTokenInformation(
|
||||
token.as_raw_handle(),
|
||||
TokenIsAppContainer,
|
||||
(&mut contained as *mut u32).cast(),
|
||||
size_of::<u32>() as u32,
|
||||
&mut length,
|
||||
)
|
||||
} == 0
|
||||
|| contained != 1
|
||||
|| length != 4
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let query = |class: TOKEN_INFORMATION_CLASS| -> Result<Vec<usize>> {
|
||||
let mut bytes = 0;
|
||||
unsafe {
|
||||
GetTokenInformation(
|
||||
token.as_raw_handle(),
|
||||
class,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
&mut bytes,
|
||||
);
|
||||
}
|
||||
if !(4..=4096).contains(&bytes) {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut buffer = vec![0usize; (bytes as usize).div_ceil(size_of::<usize>())];
|
||||
let capacity = bytes;
|
||||
if unsafe {
|
||||
GetTokenInformation(
|
||||
token.as_raw_handle(),
|
||||
class,
|
||||
buffer.as_mut_ptr().cast(),
|
||||
capacity,
|
||||
&mut bytes,
|
||||
)
|
||||
} == 0
|
||||
|| bytes < 4
|
||||
|| bytes > capacity
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
Ok(buffer)
|
||||
};
|
||||
let identity = query(TokenAppContainerSid)?;
|
||||
if identity.len() * size_of::<usize>() < size_of::<TOKEN_APPCONTAINER_INFORMATION>() {
|
||||
return Err(bad());
|
||||
}
|
||||
let identity = unsafe { &*identity.as_ptr().cast::<TOKEN_APPCONTAINER_INFORMATION>() };
|
||||
if identity.TokenAppContainer.is_null()
|
||||
|| unsafe { EqualSid(identity.TokenAppContainer, profile.sid()) } == 0
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let capabilities = query(TokenCapabilities)?;
|
||||
if unsafe { *capabilities.as_ptr().cast::<u32>() } != 0 {
|
||||
return Err(bad());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn data(profile: &Profile, entry: &Path) -> LaunchData {
|
||||
let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap());
|
||||
let folder = profile.folder().unwrap();
|
||||
LaunchData::new(
|
||||
entry,
|
||||
&[],
|
||||
&system,
|
||||
&folder,
|
||||
&folder.join("Temp"),
|
||||
&std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn dropped_suspended_owner_terminates_process_and_wrong_identity_is_rejected() {
|
||||
let profile = Profile::create().unwrap();
|
||||
let other = Profile::create().unwrap();
|
||||
let entry = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap())
|
||||
.join("System32/cmd.exe");
|
||||
let suspended = Suspended::create(&profile, &entry, data(&profile, &entry)).unwrap();
|
||||
assert_eq!(
|
||||
verify_identity(&suspended.0.handles, &other)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"EXTENSION_PROCESS_IDENTITY_INVALID"
|
||||
);
|
||||
let observer = suspended.0.handles.process.try_clone().unwrap();
|
||||
assert_eq!(
|
||||
unsafe { WaitForSingleObject(observer.as_raw_handle(), 0) },
|
||||
WAIT_TIMEOUT
|
||||
);
|
||||
drop(suspended); // Never resumed any command interpreter instruction.
|
||||
assert_eq!(
|
||||
unsafe { WaitForSingleObject(observer.as_raw_handle(), 5000) },
|
||||
WAIT_OBJECT_0
|
||||
);
|
||||
drop(observer);
|
||||
other.remove().unwrap();
|
||||
profile.remove().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn invalid_command_and_missing_entry_fail_without_consuming_profile() {
|
||||
let profile = Profile::create().unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let entry = directory.path().join("does-not-exist.exe");
|
||||
let error = Suspended::create(&profile, &entry, data(&profile, &entry))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.code, "EXTENSION_PROCESS_CREATE_FAILED");
|
||||
let mut invalid = data(&profile, &entry);
|
||||
*invalid.command_mut().last_mut().unwrap() = 1;
|
||||
assert_eq!(
|
||||
Suspended::create(&profile, &entry, invalid)
|
||||
.err()
|
||||
.unwrap()
|
||||
.code,
|
||||
"EXTENSION_PROCESS_CREATE_FAILED"
|
||||
);
|
||||
assert!(profile.folder().unwrap().is_dir());
|
||||
profile.remove().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -60,3 +60,6 @@ pub mod extension_container;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod extension_launch_data;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod extension_process;
|
||||
|
||||
@@ -3,6 +3,10 @@ use std::net::{SocketAddr, TcpStream, UdpSocket};
|
||||
use std::time::Duration;
|
||||
fn main() {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
if args.get(1).is_some_and(|s| s == "wait") {
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
std::process::exit(84);
|
||||
}
|
||||
if args.get(1).is_some_and(|s| s == "launch") {
|
||||
let expected = [
|
||||
"launch",
|
||||
|
||||
Reference in New Issue
Block a user