mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 08:06:30 +00:00
* test: wait for windows cwd commands to finish * test: wait for server readiness before sigterm
5684 lines
199 KiB
Rust
5684 lines
199 KiB
Rust
use std::{
|
|
cmp::Ordering,
|
|
collections::{HashMap, HashSet, VecDeque},
|
|
ffi::{c_void, OsStr},
|
|
mem::{size_of, MaybeUninit},
|
|
os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle},
|
|
path::PathBuf,
|
|
ptr::{copy_nonoverlapping, null_mut},
|
|
sync::{
|
|
atomic::{AtomicU32, AtomicU64, Ordering as AtomicOrdering},
|
|
Arc, LazyLock, Mutex, OnceLock,
|
|
},
|
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
mod clipboard_image;
|
|
mod config_backup;
|
|
mod notifications;
|
|
mod power;
|
|
pub(crate) use notifications::{
|
|
foreground_desktop_notification_host, maybe_activate_desktop_notification,
|
|
show_actionable_desktop_notification, show_desktop_notification,
|
|
};
|
|
pub(crate) use power::monitor_host_power;
|
|
|
|
static ALLOW_UNELEVATED_CLIENTS: OnceLock<bool> = OnceLock::new();
|
|
|
|
pub(crate) struct PrivateLocalListener {
|
|
listener: interprocess::os::windows::named_pipe::PipeListener<
|
|
interprocess::os::windows::named_pipe::pipe_mode::Bytes,
|
|
interprocess::os::windows::named_pipe::pipe_mode::Bytes,
|
|
>,
|
|
stream_nonblocking: std::sync::atomic::AtomicBool,
|
|
}
|
|
|
|
impl PrivateLocalListener {
|
|
pub(crate) fn bind(path: &std::path::Path) -> std::io::Result<Self> {
|
|
use interprocess::os::windows::{
|
|
named_pipe::PipeListenerOptions, security_descriptor::SecurityDescriptor,
|
|
};
|
|
|
|
let sddl = widestring::U16CString::from_str("D:P(A;;GA;;;SY)(A;;GA;;;OW)")
|
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
|
|
let security_descriptor = SecurityDescriptor::deserialize(&sddl)?;
|
|
// The default 512-byte buffers serialize busy SSH output behind reader polling.
|
|
let listener = PipeListenerOptions::new()
|
|
.path(format!(r"\\.\pipe\{}", path.to_string_lossy()))
|
|
.input_buffer_size_hint(64 * 1024)
|
|
.output_buffer_size_hint(64 * 1024)
|
|
.security_descriptor(Some(security_descriptor))
|
|
.create_duplex()?;
|
|
Ok(Self {
|
|
listener,
|
|
stream_nonblocking: std::sync::atomic::AtomicBool::new(false),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn accept(&self) -> std::io::Result<crate::ipc::LocalStream> {
|
|
let stream = self.listener.accept()?;
|
|
stream.set_nonblocking(self.stream_nonblocking.load(AtomicOrdering::Acquire))?;
|
|
Ok(crate::ipc::LocalStream::NamedPipe(stream.into()))
|
|
}
|
|
|
|
pub(crate) fn set_nonblocking(
|
|
&self,
|
|
mode: interprocess::local_socket::ListenerNonblockingMode,
|
|
) -> std::io::Result<()> {
|
|
self.listener.set_nonblocking(mode.accept_nonblocking())?;
|
|
self.stream_nonblocking
|
|
.store(mode.stream_nonblocking(), AtomicOrdering::Release);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(crate) struct ChildPipe(std::fs::File);
|
|
|
|
impl ChildPipe {
|
|
pub(crate) fn new_reader(pipe: impl Into<OwnedHandle>) -> std::io::Result<Self> {
|
|
// Rust's stdout/stderr handles lack permission to change pipe mode. Peek before
|
|
// reading instead; each pipe has exactly one reader.
|
|
Ok(Self(std::fs::File::from(pipe.into())))
|
|
}
|
|
|
|
pub(crate) fn new_writer(pipe: impl Into<OwnedHandle>) -> std::io::Result<Self> {
|
|
use windows_sys::Win32::System::Pipes::{SetNamedPipeHandleState, PIPE_NOWAIT};
|
|
let file = std::fs::File::from(pipe.into());
|
|
let mode = PIPE_NOWAIT;
|
|
if unsafe { SetNamedPipeHandleState(file.as_raw_handle(), &mode, null_mut(), null_mut()) }
|
|
== 0
|
|
{
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(Self(file))
|
|
}
|
|
|
|
pub(crate) fn wait(&self, _writing: bool) -> std::io::Result<()> {
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl std::io::Read for ChildPipe {
|
|
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
|
use windows_sys::Win32::System::Pipes::PeekNamedPipe;
|
|
if buffer.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
let mut available = 0;
|
|
if unsafe {
|
|
PeekNamedPipe(
|
|
self.0.as_raw_handle(),
|
|
null_mut(),
|
|
0,
|
|
null_mut(),
|
|
&mut available,
|
|
null_mut(),
|
|
)
|
|
} == 0
|
|
{
|
|
let error = std::io::Error::last_os_error();
|
|
return if crate::ipc::is_connection_closed_error(&error) {
|
|
Ok(0)
|
|
} else {
|
|
Err(error)
|
|
};
|
|
}
|
|
if available == 0 {
|
|
return Err(std::io::ErrorKind::WouldBlock.into());
|
|
}
|
|
let count = buffer.len().min(available as usize);
|
|
self.0.read(&mut buffer[..count])
|
|
}
|
|
}
|
|
|
|
impl std::io::Write for ChildPipe {
|
|
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
|
match self.0.write(buffer) {
|
|
Ok(0) if !buffer.is_empty() => Err(std::io::ErrorKind::WouldBlock.into()),
|
|
result => result,
|
|
}
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn allow_unelevated_clients() {
|
|
let _ = ALLOW_UNELEVATED_CLIENTS.set(true);
|
|
}
|
|
|
|
pub(crate) fn probe_local_server(path: &std::path::Path) -> std::io::Result<()> {
|
|
use interprocess::os::windows::named_pipe::{pipe_mode::Bytes, DuplexPipeStream};
|
|
use interprocess::ConnectWaitMode;
|
|
|
|
// The local-socket wrapper ignores wait_mode on Windows in interprocess
|
|
// 2.4.2. Its named-pipe API honors it without importing/reopening a handle.
|
|
let name = format!(r"\\.\pipe\{}", path.to_string_lossy());
|
|
DuplexPipeStream::<Bytes>::connect_by_path_with_wait_mode(
|
|
name.as_str(),
|
|
ConnectWaitMode::Timeout(Duration::from_millis(500)),
|
|
)
|
|
.map(|_| ())
|
|
.map_err(local_server_connection_error)
|
|
}
|
|
|
|
pub(crate) fn connect_local_stream_with_timeout(
|
|
path: &std::path::Path,
|
|
timeout: Duration,
|
|
) -> std::io::Result<crate::ipc::LocalStream> {
|
|
use interprocess::os::windows::named_pipe::{pipe_mode::Bytes, DuplexPipeStream};
|
|
let name = format!(r"\\.\pipe\{}", path.to_string_lossy());
|
|
DuplexPipeStream::<Bytes>::connect_by_path_with_wait_mode(
|
|
name.as_str(),
|
|
interprocess::ConnectWaitMode::Timeout(timeout),
|
|
)
|
|
.map(|pipe| crate::ipc::LocalStream::NamedPipe(pipe.into()))
|
|
.map_err(local_server_connection_error)
|
|
}
|
|
|
|
pub(crate) fn local_server_security_descriptor(
|
|
) -> std::io::Result<interprocess::os::windows::security_descriptor::SecurityDescriptor> {
|
|
use windows_sys::Win32::Security::{
|
|
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
|
|
};
|
|
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
|
|
|
let mut token = null_mut();
|
|
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let token = unsafe { OwnedHandle::from_raw_handle(token) };
|
|
let mut elevation = TOKEN_ELEVATION::default();
|
|
let mut needed = 0;
|
|
if unsafe {
|
|
GetTokenInformation(
|
|
token.as_raw_handle(),
|
|
TokenElevation,
|
|
(&mut elevation as *mut TOKEN_ELEVATION).cast(),
|
|
size_of::<TOKEN_ELEVATION>() as u32,
|
|
&mut needed,
|
|
)
|
|
} == 0
|
|
{
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let allow_unelevated = ALLOW_UNELEVATED_CLIENTS.get().copied().unwrap_or(false);
|
|
let integrity = if elevation.TokenIsElevated != 0 && !allow_unelevated {
|
|
"HI"
|
|
} else {
|
|
"ME"
|
|
};
|
|
// The account DACL alone cannot distinguish ordinary and elevated clients.
|
|
// The integrity label blocks both reading and writing from lower levels.
|
|
user_security_descriptor("GRGW", &format!("S:(ML;;NRNW;;;{integrity})"))
|
|
}
|
|
|
|
fn user_security_descriptor(
|
|
access: &str,
|
|
integrity_label: &str,
|
|
) -> std::io::Result<interprocess::os::windows::security_descriptor::SecurityDescriptor> {
|
|
use interprocess::os::windows::security_descriptor::SecurityDescriptor;
|
|
use widestring::{U16CStr, U16CString};
|
|
use windows_sys::Win32::Security::{
|
|
Authorization::ConvertSidToStringSidW, GetTokenInformation, TokenUser, TOKEN_QUERY,
|
|
TOKEN_USER,
|
|
};
|
|
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
|
|
|
let mut token = null_mut();
|
|
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let token = unsafe { OwnedHandle::from_raw_handle(token) };
|
|
let mut needed = 0;
|
|
unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, null_mut(), 0, &mut needed) };
|
|
if needed == 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
// usize storage keeps TOKEN_USER aligned and its trailing SID alive.
|
|
let mut buffer = vec![0usize; (needed as usize).div_ceil(size_of::<usize>())];
|
|
if unsafe {
|
|
GetTokenInformation(
|
|
token.as_raw_handle(),
|
|
TokenUser,
|
|
buffer.as_mut_ptr().cast(),
|
|
needed,
|
|
&mut needed,
|
|
)
|
|
} == 0
|
|
{
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
// SAFETY: GetTokenInformation initialized the aligned TOKEN_USER and SID.
|
|
let user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
|
|
let mut sid = null_mut();
|
|
if unsafe { ConvertSidToStringSidW(user.User.Sid, &mut sid) } == 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let sid_text = unsafe { U16CStr::from_ptr_str(sid) }.to_string_lossy();
|
|
unsafe { LocalFree(sid.cast()) };
|
|
// Use the account SID rather than the elevated token's Administrators owner.
|
|
let sddl = U16CString::from_str(format!(
|
|
"D:P(A;;GA;;;SY)(A;;{access};;;{sid_text}){integrity_label}"
|
|
))
|
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
|
|
SecurityDescriptor::deserialize(&sddl)
|
|
}
|
|
|
|
pub(crate) fn local_server_connection_error(error: std::io::Error) -> std::io::Error {
|
|
if error.kind() != std::io::ErrorKind::PermissionDenied {
|
|
return error;
|
|
}
|
|
std::io::Error::new(
|
|
error.kind(),
|
|
format!(
|
|
"For an elevated server, use an administrator terminal. Sharing with ordinary \
|
|
clients requires stopping it there and restarting its `herdr server` command \
|
|
with --allow-unelevated-clients (closes panes). {error}"
|
|
),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn windows_virtual_terminal_input_active() -> bool {
|
|
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
|
|
use windows_sys::Win32::System::Console::{
|
|
GetConsoleMode, GetStdHandle, ENABLE_VIRTUAL_TERMINAL_INPUT, STD_INPUT_HANDLE,
|
|
};
|
|
|
|
let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
|
|
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
|
|
return false;
|
|
}
|
|
let mut mode = 0;
|
|
(unsafe { GetConsoleMode(handle, &mut mode) } != 0) && mode & ENABLE_VIRTUAL_TERMINAL_INPUT != 0
|
|
}
|
|
|
|
pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::ChildExitReason {
|
|
// STATUS_CONTROL_C_EXIT is reported without a Unix signal by portable-pty.
|
|
if status.exit_code() == 0xC000013A {
|
|
super::ChildExitReason::Interrupted
|
|
} else {
|
|
super::ChildExitReason::Exited
|
|
}
|
|
}
|
|
|
|
pub(crate) fn host_shutdown_in_progress() -> bool {
|
|
use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_SHUTTINGDOWN};
|
|
|
|
// This describes the current Windows session, including logoff. Child exit
|
|
// codes alone cannot distinguish host shutdown from ordinary process failure.
|
|
unsafe { GetSystemMetrics(SM_SHUTTINGDOWN) != 0 }
|
|
}
|
|
|
|
pub(crate) struct RemoteBridgeWake;
|
|
|
|
impl RemoteBridgeWake {
|
|
pub(crate) fn new() -> std::io::Result<Self> {
|
|
Ok(Self)
|
|
}
|
|
|
|
pub(crate) fn cancel(&self) -> std::io::Result<()> {
|
|
// The named-pipe reader checks its cancellation flag between peeks.
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn wait(&self, _stream: &crate::ipc::LocalStream) -> std::io::Result<()> {
|
|
// Synchronous named pipes still use peek-before-read polling on Windows.
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn wait_client_stream_readable(
|
|
_stream: &crate::ipc::LocalStream,
|
|
timeout: Duration,
|
|
) -> std::io::Result<()> {
|
|
// Sync named pipes have no read timeout. The caller peeks before each read and checks its
|
|
// cancellation flag between polls, including when a frame arrives in several fragments.
|
|
std::thread::sleep(timeout.min(Duration::from_millis(2)));
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn forward_remote_bridge_stdio(
|
|
stream: crate::ipc::LocalStream,
|
|
_idle_timeout: bool,
|
|
) -> std::io::Result<()> {
|
|
use interprocess::TryClone as _;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
let mut stdout = std::io::stdout().lock();
|
|
let mut socket_to_stdout = stream.try_clone()?;
|
|
let mut stdin_to_socket = stream;
|
|
let upload_done = Arc::new(AtomicBool::new(false));
|
|
let upload_done_worker = Arc::clone(&upload_done);
|
|
let _upload = std::thread::spawn(move || {
|
|
let mut stdin = std::io::stdin();
|
|
let _ = copy_flush(&mut stdin, &mut stdin_to_socket);
|
|
upload_done_worker.store(true, Ordering::Release);
|
|
});
|
|
|
|
let mut buffer = [0_u8; 16 * 1024];
|
|
while !upload_done.load(Ordering::Acquire) {
|
|
match crate::ipc::poll_local_stream_read_count(&mut socket_to_stdout, &mut buffer)? {
|
|
crate::ipc::LocalStreamReadCount::Data(read) => {
|
|
std::io::Write::write_all(&mut stdout, &buffer[..read])?;
|
|
std::io::Write::flush(&mut stdout)?;
|
|
}
|
|
crate::ipc::LocalStreamReadCount::Pending => {
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
}
|
|
crate::ipc::LocalStreamReadCount::Closed => break,
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_flush<R: std::io::Read, W: std::io::Write>(
|
|
reader: &mut R,
|
|
writer: &mut W,
|
|
) -> std::io::Result<()> {
|
|
let mut buffer = [0_u8; 16 * 1024];
|
|
loop {
|
|
let read = match reader.read(&mut buffer) {
|
|
Ok(0) => return Ok(()),
|
|
Ok(read) => read,
|
|
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
|
|
Err(err) => return Err(err),
|
|
};
|
|
writer.write_all(&buffer[..read])?;
|
|
writer.flush()?;
|
|
}
|
|
}
|
|
|
|
pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> {
|
|
crossterm::terminal::size()
|
|
}
|
|
|
|
pub(crate) fn replace_file(
|
|
source: &std::path::Path,
|
|
destination: &std::path::Path,
|
|
) -> std::io::Result<()> {
|
|
use std::os::windows::ffi::OsStrExt;
|
|
use windows_sys::Win32::Storage::FileSystem::{
|
|
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
|
};
|
|
|
|
let source = source
|
|
.as_os_str()
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect::<Vec<_>>();
|
|
let destination = destination
|
|
.as_os_str()
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect::<Vec<_>>();
|
|
let moved = unsafe {
|
|
MoveFileExW(
|
|
source.as_ptr(),
|
|
destination.as_ptr(),
|
|
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
|
)
|
|
};
|
|
if moved == 0 {
|
|
Err(std::io::Error::last_os_error())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn config_file_link_count(path: &std::path::Path) -> std::io::Result<u64> {
|
|
use windows_sys::Win32::Storage::FileSystem::{
|
|
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
|
|
};
|
|
let file = std::fs::File::open(path)?;
|
|
let mut info = BY_HANDLE_FILE_INFORMATION::default();
|
|
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(u64::from(info.nNumberOfLinks))
|
|
}
|
|
|
|
pub(crate) fn create_config_temporary(
|
|
path: &std::path::Path,
|
|
private: bool,
|
|
) -> std::io::Result<std::fs::File> {
|
|
if !private {
|
|
return std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path);
|
|
}
|
|
use interprocess::os::windows::security_descriptor::AsSecurityDescriptorExt as _;
|
|
use windows_sys::Win32::{
|
|
Foundation::GENERIC_WRITE,
|
|
Storage::FileSystem::{
|
|
CreateFileW, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_DELETE, FILE_SHARE_READ,
|
|
FILE_SHARE_WRITE,
|
|
},
|
|
};
|
|
let descriptor = user_security_descriptor("GA", "")?;
|
|
let mut attributes = SECURITY_ATTRIBUTES {
|
|
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
|
|
lpSecurityDescriptor: null_mut(),
|
|
bInheritHandle: 0,
|
|
};
|
|
descriptor.write_to_security_attributes(&mut attributes);
|
|
let path = extended_length_path(path)?;
|
|
let handle = unsafe {
|
|
CreateFileW(
|
|
path.as_ptr(),
|
|
GENERIC_WRITE,
|
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
|
&attributes,
|
|
CREATE_NEW,
|
|
FILE_ATTRIBUTE_NORMAL,
|
|
null_mut(),
|
|
)
|
|
};
|
|
if handle == INVALID_HANDLE_VALUE {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
// CreateFileW returned an owned handle; File closes it exactly once.
|
|
Ok(unsafe { std::fs::File::from_raw_handle(handle) })
|
|
}
|
|
|
|
pub(crate) fn write_config_temporary(
|
|
source: Option<&std::path::Path>,
|
|
temporary: &std::path::Path,
|
|
contents: &[u8],
|
|
) -> std::io::Result<()> {
|
|
use std::io::Write;
|
|
if source.is_some() {
|
|
// If preparation finds an existing file, leave it to the recovery-backed
|
|
// path instead of applying replacement-file permissions.
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::AlreadyExists,
|
|
"config appeared while preparing a new file; retry the update",
|
|
));
|
|
}
|
|
let mut output = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(temporary)?;
|
|
output.write_all(contents)?;
|
|
output.sync_all()
|
|
}
|
|
|
|
pub(crate) fn check_config_write_target(target: &std::path::Path) -> std::io::Result<()> {
|
|
config_backup::check_recovery(target)
|
|
}
|
|
|
|
pub(crate) fn write_existing_config(
|
|
target: &std::path::Path,
|
|
contents: &[u8],
|
|
) -> std::io::Result<bool> {
|
|
config_backup::write_existing(target, contents)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn config_security_descriptor(
|
|
path: &std::path::Path,
|
|
information: windows_sys::Win32::Security::OBJECT_SECURITY_INFORMATION,
|
|
) -> std::io::Result<Vec<u8>> {
|
|
use windows_sys::Win32::Security::GetFileSecurityW;
|
|
let path = extended_length_path(path)?;
|
|
let mut needed = 0;
|
|
unsafe { GetFileSecurityW(path.as_ptr(), information, null_mut(), 0, &mut needed) };
|
|
if needed == 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let mut descriptor = vec![0_u8; needed as usize];
|
|
if unsafe {
|
|
GetFileSecurityW(
|
|
path.as_ptr(),
|
|
information,
|
|
descriptor.as_mut_ptr().cast(),
|
|
needed,
|
|
&mut needed,
|
|
)
|
|
} == 0
|
|
{
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(descriptor)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn config_security_sddl(
|
|
descriptor: &mut [u8],
|
|
information: windows_sys::Win32::Security::OBJECT_SECURITY_INFORMATION,
|
|
) -> std::io::Result<Vec<u16>> {
|
|
use windows_sys::Win32::Security::{
|
|
Authorization::{ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1},
|
|
SACL_SECURITY_INFORMATION,
|
|
};
|
|
let mut text = null_mut();
|
|
if unsafe {
|
|
ConvertSecurityDescriptorToStringSecurityDescriptorW(
|
|
descriptor.as_mut_ptr().cast(),
|
|
SDDL_REVISION_1,
|
|
// Only labels were queried from the SACL. Serialize that returned
|
|
// SACL too; this does not request audit access to either file.
|
|
information | SACL_SECURITY_INFORMATION,
|
|
&mut text,
|
|
null_mut(),
|
|
)
|
|
} == 0
|
|
{
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let result = unsafe { widestring::U16CStr::from_ptr_str(text) }
|
|
.as_slice()
|
|
.to_vec();
|
|
unsafe { LocalFree(text.cast()) };
|
|
Ok(result)
|
|
}
|
|
|
|
pub(crate) fn set_default_plugin_pane_pwd(
|
|
_env: &mut Vec<(String, String)>,
|
|
_cwd: &std::path::Path,
|
|
) {
|
|
}
|
|
|
|
#[cfg(target_pointer_width = "64")]
|
|
use windows_sys::{
|
|
Wdk::System::Threading::ProcessWow64Information, Win32::System::Kernel::STRING32,
|
|
};
|
|
|
|
use windows_sys::{
|
|
Wdk::System::Threading::ProcessCommandLineInformation,
|
|
Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation},
|
|
Win32::{
|
|
Foundation::{
|
|
CloseHandle, GlobalFree, LocalFree, FILETIME, HANDLE, HWND, INVALID_HANDLE_VALUE,
|
|
MAX_PATH, NTSTATUS, STATUS_BUFFER_OVERFLOW, STATUS_BUFFER_TOO_SMALL,
|
|
STATUS_INFO_LENGTH_MISMATCH, STATUS_SUCCESS, UNICODE_STRING,
|
|
},
|
|
Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN},
|
|
Security::SECURITY_ATTRIBUTES,
|
|
Storage::FileSystem::CreateDirectoryW,
|
|
System::{
|
|
Console::GetConsoleWindow,
|
|
DataExchange::{
|
|
CloseClipboard, CountClipboardFormats, EmptyClipboard, EnumClipboardFormats,
|
|
GetClipboardData, GetClipboardOwner, GetClipboardSequenceNumber, OpenClipboard,
|
|
RegisterClipboardFormatW, SetClipboardData,
|
|
},
|
|
Diagnostics::{
|
|
Debug::ReadProcessMemory,
|
|
ToolHelp::{
|
|
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, Thread32First,
|
|
Thread32Next, PROCESSENTRY32W, TH32CS_SNAPPROCESS, TH32CS_SNAPTHREAD,
|
|
THREADENTRY32,
|
|
},
|
|
},
|
|
JobObjects::{
|
|
AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob,
|
|
JobObjectExtendedLimitInformation, QueryInformationJobObject,
|
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
},
|
|
Memory::{
|
|
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE,
|
|
MEMORY_BASIC_INFORMATION,
|
|
},
|
|
Ole::{CF_DIB, CF_DIBV5, CF_LOCALE, CF_OEMTEXT, CF_TEXT, CF_UNICODETEXT},
|
|
Threading::{
|
|
GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, IsWow64Process2,
|
|
OpenProcess, OpenThread, QueryFullProcessImageNameW, ResumeThread,
|
|
TerminateProcess, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS,
|
|
PROCESS_BASIC_INFORMATION, PROCESS_QUERY_INFORMATION,
|
|
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, THREAD_SUSPEND_RESUME,
|
|
},
|
|
},
|
|
UI::{
|
|
Input::{
|
|
Ime::ImmGetDefaultIMEWnd,
|
|
KeyboardAndMouse::{
|
|
GetKeyboardLayout, SendInput, ToUnicodeEx, INPUT, INPUT_0, INPUT_KEYBOARD,
|
|
KEYBDINPUT, KEYEVENTF_KEYUP,
|
|
},
|
|
},
|
|
Shell::{CommandLineToArgvW, ShellExecuteW},
|
|
WindowsAndMessaging::{
|
|
GetForegroundWindow, GetWindowThreadProcessId, SendMessageTimeoutW,
|
|
SMTO_ABORTIFHUNG, WM_IME_CONTROL,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
use super::{ClipboardImage, ForegroundJob, Signal};
|
|
|
|
const STILL_ACTIVE: u32 = 259;
|
|
const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250);
|
|
const FOREGROUND_SELECTION_RECHECK: Duration = Duration::from_secs(5);
|
|
const FOREGROUND_SELECTION_CACHE_CAPACITY: usize = 1_024;
|
|
const FOREGROUND_SELECTION_CACHE_RETENTION: Duration = Duration::from_secs(60);
|
|
const PANE_RUNTIME_MARKER_ENV_VAR: &str = "HERDR_PANE_RUNTIME_ID";
|
|
|
|
#[cfg(test)]
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
struct ProcessInspectionCounts {
|
|
snapshots: u64,
|
|
opens: u64,
|
|
command_reads: u64,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
static PROCESS_INSPECTION_COUNTS: std::cell::RefCell<ProcessInspectionCounts> =
|
|
std::cell::RefCell::new(ProcessInspectionCounts::default());
|
|
}
|
|
|
|
/// Native processor architecture of the Windows host as an
|
|
/// `IMAGE_FILE_MACHINE_*` value. `IsWow64Process2` reports the native machine
|
|
/// even when an x64 Herdr runs under emulation on Windows ARM64, which the
|
|
/// build target alone cannot reveal.
|
|
pub(crate) fn native_machine_type() -> u16 {
|
|
let mut process_machine = 0u16;
|
|
let mut native_machine = 0u16;
|
|
// SAFETY: `GetCurrentProcess` returns a pseudo-handle and both out-pointers
|
|
// are valid for the duration of the call.
|
|
let ok = unsafe {
|
|
IsWow64Process2(
|
|
GetCurrentProcess(),
|
|
&mut process_machine,
|
|
&mut native_machine,
|
|
)
|
|
};
|
|
if ok != 0 && native_machine != 0 {
|
|
native_machine
|
|
} else {
|
|
process_machine
|
|
}
|
|
}
|
|
|
|
pub(crate) fn terminal_title_for_presentation(title: &str) -> &str {
|
|
title.strip_prefix("Administrator: ").unwrap_or(title)
|
|
}
|
|
|
|
pub(crate) fn prepare_paste_text_for_pty_platform(text: String) -> String {
|
|
text.replace("\r\n", "\n").replace('\n', "\r\n")
|
|
}
|
|
|
|
pub(crate) fn normalize_cwd_for_launch_platform(path: &std::path::Path) -> PathBuf {
|
|
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
|
use std::path::{Component, Prefix};
|
|
use windows_sys::Win32::{
|
|
Foundation::INVALID_HANDLE_VALUE,
|
|
Storage::FileSystem::{FindClose, FindFirstFileW, WIN32_FIND_DATAW},
|
|
};
|
|
|
|
fn stored_name(path: &std::path::Path) -> Option<std::ffi::OsString> {
|
|
let input = path
|
|
.as_os_str()
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect::<Vec<_>>();
|
|
let mut data = std::mem::MaybeUninit::<WIN32_FIND_DATAW>::uninit();
|
|
let handle = unsafe { FindFirstFileW(input.as_ptr(), data.as_mut_ptr()) };
|
|
if handle == INVALID_HANDLE_VALUE {
|
|
return None;
|
|
}
|
|
let data = unsafe { data.assume_init() };
|
|
unsafe { FindClose(handle) };
|
|
let len = data
|
|
.cFileName
|
|
.iter()
|
|
.position(|&ch| ch == 0)
|
|
.unwrap_or(data.cFileName.len());
|
|
Some(std::ffi::OsString::from_wide(&data.cFileName[..len]))
|
|
}
|
|
|
|
let mut normalized = PathBuf::new();
|
|
for component in path.components() {
|
|
match component {
|
|
Component::Prefix(prefix) => match prefix.kind() {
|
|
Prefix::Disk(drive) => {
|
|
normalized.push(format!("{}:", char::from(drive).to_ascii_uppercase()))
|
|
}
|
|
_ => normalized.push(prefix.as_os_str()),
|
|
},
|
|
Component::Normal(name) => {
|
|
let candidate = normalized.join(name);
|
|
normalized.push(stored_name(&candidate).unwrap_or_else(|| name.to_os_string()));
|
|
}
|
|
_ => normalized.push(component.as_os_str()),
|
|
}
|
|
}
|
|
normalized
|
|
}
|
|
|
|
pub(crate) fn plugin_runtime_path_platform(path: &std::path::Path) -> PathBuf {
|
|
use std::os::windows::ffi::OsStrExt;
|
|
|
|
let Some(candidate) = standard_windows_path(path) else {
|
|
return path.to_path_buf();
|
|
};
|
|
// Rust can canonicalize a long standard path by adding its own verbatim prefix, but native
|
|
// process consumers still need the original prefix when the plugin root exceeds MAX_PATH.
|
|
if candidate.join("").as_os_str().encode_wide().count() >= MAX_PATH as usize {
|
|
return path.to_path_buf();
|
|
}
|
|
match candidate.canonicalize() {
|
|
Ok(canonical) if canonical == path => candidate,
|
|
_ => path.to_path_buf(),
|
|
}
|
|
}
|
|
|
|
fn standard_windows_path(path: &std::path::Path) -> Option<PathBuf> {
|
|
use std::path::{Component, Prefix};
|
|
|
|
let mut components = path.components();
|
|
let Component::Prefix(prefix) = components.next()? else {
|
|
return None;
|
|
};
|
|
let mut candidate = match prefix.kind() {
|
|
Prefix::VerbatimDisk(drive) => PathBuf::from(format!("{}:", char::from(drive))),
|
|
Prefix::VerbatimUNC(server, share) => {
|
|
let mut candidate = PathBuf::from(r"\\");
|
|
candidate.push(server);
|
|
candidate.push(share);
|
|
candidate
|
|
}
|
|
_ => return None,
|
|
};
|
|
candidate.push(components.as_path());
|
|
Some(candidate)
|
|
}
|
|
|
|
/// Resolves against the current foreground layout because asynchronous console
|
|
/// records do not retain the layout that was active when the key was pressed.
|
|
pub(crate) fn resolve_base_printable_key(vk: u16, scan: u16) -> Option<char> {
|
|
// SAFETY: the foreground window and its layout are owned by Win32.
|
|
let layout = unsafe {
|
|
let thread_id = GetWindowThreadProcessId(GetForegroundWindow(), null_mut());
|
|
GetKeyboardLayout(thread_id)
|
|
};
|
|
resolve_base_printable_key_in_layout(vk, scan, layout)
|
|
}
|
|
|
|
pub(crate) fn resolve_base_printable_key_in_layout(
|
|
vk: u16,
|
|
scan: u16,
|
|
layout: windows_sys::Win32::UI::Input::KeyboardAndMouse::HKL,
|
|
) -> Option<char> {
|
|
// SAFETY: Win32 owns the handles; the fixed buffers match the API lengths.
|
|
unsafe {
|
|
let key_state = [0u8; 256];
|
|
let mut output = [0u16; 2];
|
|
let written = ToUnicodeEx(
|
|
vk.into(),
|
|
scan.into(),
|
|
key_state.as_ptr(),
|
|
output.as_mut_ptr(),
|
|
output.len() as i32,
|
|
0x4,
|
|
layout,
|
|
);
|
|
// A negative result identifies a dead key; its spacing accent is still
|
|
// the key's identity. Flag 0x4 above keeps composition state unchanged.
|
|
let units = output.get(..usize::try_from(written.unsigned_abs()).ok()?)?;
|
|
let mut chars = char::decode_utf16(units.iter().copied());
|
|
let ch = chars.next()?.ok()?;
|
|
(chars.next().is_none() && !ch.is_control()).then_some(ch)
|
|
}
|
|
}
|
|
|
|
const MAX_PROCESS_ENVIRONMENT_BYTES: usize = 256 * 1024;
|
|
const PROCESS_ENVIRONMENT_READ_CHUNK_BYTES: usize = 16 * 1024;
|
|
const PROCESS_RUNTIME_MARKER_CACHE_CAPACITY: usize = 1_024;
|
|
const PROCESS_RUNTIME_MARKER_CACHE_RETENTION: Duration = Duration::from_secs(60);
|
|
const PROCESS_RUNTIME_MARKER_NEGATIVE_TTL: Duration = Duration::from_secs(1);
|
|
|
|
static NEXT_PANE_RUNTIME_MARKER: AtomicU64 = AtomicU64::new(1);
|
|
static PROCESS_RUNTIME_MARKER_CACHE: LazyLock<Mutex<HashMap<u32, CachedProcessRuntimeMarker>>> =
|
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
static GIT_BASH_PROCESS_CACHE: LazyLock<Mutex<HashMap<u32, CachedGitBashProcess>>> =
|
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
|
|
pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths {
|
|
super::RemoteSshConfigPaths {
|
|
user_config: std::env::var_os("USERPROFILE")
|
|
.map(PathBuf::from)
|
|
.map(|home| home.join(".ssh").join("config")),
|
|
system_config: std::env::var_os("PROGRAMDATA")
|
|
.map(PathBuf::from)
|
|
.map(|dir| dir.join("ssh").join("ssh_config")),
|
|
multiplexing: false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::io::Result<PathBuf> {
|
|
let base = remote_private_temp_base();
|
|
std::fs::create_dir_all(&base)?;
|
|
for attempt in 0..100 {
|
|
let dir = base.join(format!("ssh-{}-{attempt}", std::process::id()));
|
|
match create_remote_private_dir(&dir) {
|
|
Ok(()) => return Ok(dir),
|
|
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::AlreadyExists,
|
|
"failed to create private herdr ssh config directory",
|
|
))
|
|
}
|
|
|
|
pub(crate) fn create_remote_ssh_config_file(
|
|
path: &std::path::Path,
|
|
) -> std::io::Result<std::fs::File> {
|
|
std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path)
|
|
}
|
|
|
|
pub(crate) fn create_remote_private_dir(path: &std::path::Path) -> std::io::Result<()> {
|
|
use interprocess::os::windows::security_descriptor::{
|
|
AsSecurityDescriptorExt as _, SecurityDescriptor,
|
|
};
|
|
use widestring::U16CString;
|
|
|
|
let sddl = U16CString::from_str("D:P(A;OICI;GA;;;SY)(A;OICI;GA;;;OW)")
|
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
|
|
let security_descriptor = SecurityDescriptor::deserialize(&sddl)?;
|
|
let mut security_attributes = SECURITY_ATTRIBUTES {
|
|
nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX),
|
|
lpSecurityDescriptor: null_mut(),
|
|
bInheritHandle: 0,
|
|
};
|
|
security_descriptor.write_to_security_attributes(&mut security_attributes);
|
|
let path = extended_length_path(path)?;
|
|
if unsafe { CreateDirectoryW(path.as_ptr(), &security_attributes) } != 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(std::io::Error::last_os_error())
|
|
}
|
|
}
|
|
|
|
fn extended_length_path(path: &std::path::Path) -> std::io::Result<Vec<u16>> {
|
|
use std::os::windows::ffi::OsStrExt as _;
|
|
|
|
let path = std::path::absolute(path)?;
|
|
let wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
|
|
let mut extended = if wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16])
|
|
|| wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'.' as u16, b'\\' as u16])
|
|
{
|
|
wide
|
|
} else if wide.starts_with(&[b'\\' as u16, b'\\' as u16]) {
|
|
"\\\\?\\UNC\\"
|
|
.encode_utf16()
|
|
.chain(wide.into_iter().skip(2))
|
|
.collect()
|
|
} else {
|
|
"\\\\?\\".encode_utf16().chain(wide).collect()
|
|
};
|
|
extended.push(0);
|
|
Ok(extended)
|
|
}
|
|
|
|
pub(crate) fn remote_private_temp_base() -> PathBuf {
|
|
crate::config::state_dir().join("remote")
|
|
}
|
|
|
|
pub(crate) fn remote_bridge_endpoint_path(_readable_name: &str, short_name: &str) -> PathBuf {
|
|
remote_private_temp_base().join(short_name)
|
|
}
|
|
|
|
pub(crate) fn remote_reattach_program(program: &str) -> String {
|
|
let path = std::env::current_exe()
|
|
.ok()
|
|
.filter(|path| path.is_absolute())
|
|
.unwrap_or_else(|| PathBuf::from(program));
|
|
format!(
|
|
"& {}",
|
|
remote_reattach_argument(&path.display().to_string())
|
|
)
|
|
}
|
|
|
|
pub(crate) fn remote_reattach_argument(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
}
|
|
|
|
/// Encode native or targeted semantic Win32 input for a compatible ConPTY destination.
|
|
pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option<Vec<u8>> {
|
|
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
|
|
|
let (virtual_key_code, virtual_scan_code, unicode, control_key_state) =
|
|
if let Some(record) = key.windows_record() {
|
|
(
|
|
record.virtual_key_code,
|
|
record.virtual_scan_code,
|
|
record.unicode,
|
|
record.control_key_state,
|
|
)
|
|
} else if key.code == KeyCode::Esc
|
|
&& key.modifiers.is_empty()
|
|
&& key.kind == KeyEventKind::Press
|
|
&& key.vt_bytes().is_none()
|
|
{
|
|
return Some(b"\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_".to_vec());
|
|
} else if key.code == KeyCode::Enter && key.modifiers == KeyModifiers::SHIFT {
|
|
(13, 28, 13, 16)
|
|
} else {
|
|
return None;
|
|
};
|
|
let key_down = key.kind != KeyEventKind::Release;
|
|
let repeat_count = if key_down { key.repeat_count.max(1) } else { 1 };
|
|
|
|
Some(
|
|
format!(
|
|
"\x1b[{virtual_key_code};{virtual_scan_code};{unicode};{};{control_key_state};{repeat_count}_",
|
|
u8::from(key_down),
|
|
)
|
|
.into_bytes(),
|
|
)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CachedProcessSnapshot {
|
|
built_at: Instant,
|
|
snapshot: Arc<ProcessSnapshot>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ProcessSnapshotCache {
|
|
cached: Option<CachedProcessSnapshot>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ProcessSignature {
|
|
pid: u32,
|
|
parent_pid: u32,
|
|
name: String,
|
|
}
|
|
|
|
impl ProcessSignature {
|
|
fn from_entry(entry: &WindowsProcessEntry) -> Self {
|
|
Self {
|
|
pid: entry.pid,
|
|
parent_pid: entry.parent_pid,
|
|
name: entry.name.clone(),
|
|
}
|
|
}
|
|
|
|
fn matches(&self, entry: Option<&WindowsProcessEntry>) -> bool {
|
|
entry.is_some_and(|entry| {
|
|
self.pid == entry.pid && self.parent_pid == entry.parent_pid && self.name == entry.name
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ProcessIdentity {
|
|
Handle(OwnedHandle),
|
|
#[cfg(test)]
|
|
Stub {
|
|
running: bool,
|
|
creation_time: Option<u64>,
|
|
},
|
|
}
|
|
|
|
impl ProcessIdentity {
|
|
fn open(pid: u32) -> Option<Self> {
|
|
#[cfg(test)]
|
|
PROCESS_INSPECTION_COUNTS.with_borrow_mut(|counts| counts.opens += 1);
|
|
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
|
if handle.is_null() {
|
|
return None;
|
|
}
|
|
let handle = unsafe { OwnedHandle::from_raw_handle(handle.cast()) };
|
|
Some(Self::Handle(handle))
|
|
}
|
|
|
|
fn running(&self) -> bool {
|
|
match self {
|
|
Self::Handle(handle) => {
|
|
let mut exit_code = 0;
|
|
let read = unsafe {
|
|
GetExitCodeProcess(handle.as_raw_handle().cast(), &mut exit_code) != 0
|
|
};
|
|
read && exit_code == STILL_ACTIVE
|
|
}
|
|
#[cfg(test)]
|
|
Self::Stub { running, .. } => *running,
|
|
}
|
|
}
|
|
|
|
fn creation_time(&self) -> Option<u64> {
|
|
match self {
|
|
Self::Handle(handle) => process_creation_time(handle.as_raw_handle().cast()),
|
|
#[cfg(test)]
|
|
Self::Stub { creation_time, .. } => *creation_time,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CachedForegroundSelection {
|
|
shell: ProcessSignature,
|
|
selected: ProcessSignature,
|
|
descendants: Vec<ProcessSignature>,
|
|
descendant_identities: Vec<ProcessIdentity>,
|
|
shell_identity: ProcessIdentity,
|
|
selected_identity: ProcessIdentity,
|
|
job: ForegroundJob,
|
|
verified_at: Instant,
|
|
last_used: Instant,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ForegroundSelectionCache {
|
|
entries: HashMap<u32, CachedForegroundSelection>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CachedProcessRuntimeMarker {
|
|
creation_time: u64,
|
|
marker: Option<String>,
|
|
cached_at: Instant,
|
|
last_used: Instant,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CachedGitBashProcess {
|
|
creation_time: u64,
|
|
is_git_bash: bool,
|
|
last_used: Instant,
|
|
}
|
|
|
|
static FOREGROUND_PROCESS_SNAPSHOT_CACHE: Mutex<ProcessSnapshotCache> =
|
|
Mutex::new(ProcessSnapshotCache { cached: None });
|
|
static FOREGROUND_SELECTION_CACHE: LazyLock<Mutex<ForegroundSelectionCache>> =
|
|
LazyLock::new(|| Mutex::new(ForegroundSelectionCache::default()));
|
|
|
|
pub(crate) fn should_draw_host_cursor_by_default() -> bool {
|
|
true
|
|
}
|
|
|
|
pub(crate) fn should_query_host_terminal_palette() -> bool {
|
|
false
|
|
}
|
|
|
|
/// The machine's node name, as shown by tmux's `#h`.
|
|
pub(crate) fn hostname() -> Option<String> {
|
|
std::env::var("COMPUTERNAME")
|
|
.ok()
|
|
.filter(|name| !name.is_empty())
|
|
}
|
|
|
|
pub(crate) fn local_datetime() -> Option<time::PrimitiveDateTime> {
|
|
let mut timestamp: libc::time_t = 0;
|
|
if unsafe { libc::time(&mut timestamp) } == -1 {
|
|
return None;
|
|
}
|
|
let mut local: libc::tm = unsafe { std::mem::zeroed() };
|
|
if unsafe { libc::localtime_s(&mut local, ×tamp) } != 0 {
|
|
return None;
|
|
}
|
|
let month = time::Month::try_from(u8::try_from(local.tm_mon + 1).ok()?).ok()?;
|
|
let date = time::Date::from_calendar_date(
|
|
local.tm_year + 1900,
|
|
month,
|
|
u8::try_from(local.tm_mday).ok()?,
|
|
)
|
|
.ok()?;
|
|
let time = time::Time::from_hms(
|
|
u8::try_from(local.tm_hour).ok()?,
|
|
u8::try_from(local.tm_min).ok()?,
|
|
u8::try_from(local.tm_sec).ok()?,
|
|
)
|
|
.ok()?;
|
|
Some(time::PrimitiveDateTime::new(date, time))
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct WindowsProcessCommand {
|
|
creation_time: Option<u64>,
|
|
argv0: Option<String>,
|
|
argv: Option<Vec<String>>,
|
|
cmdline: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct WindowsProcessEntry {
|
|
pid: u32,
|
|
parent_pid: u32,
|
|
name: String,
|
|
command: OnceLock<WindowsProcessCommand>,
|
|
}
|
|
|
|
impl WindowsProcessEntry {
|
|
fn command(&self) -> &WindowsProcessCommand {
|
|
self.command
|
|
.get_or_init(|| read_process_command(self.pid, &self.name))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ProcessSnapshot {
|
|
entries: Vec<WindowsProcessEntry>,
|
|
entry_by_pid: HashMap<u32, usize>,
|
|
children_by_parent: HashMap<u32, Vec<usize>>,
|
|
agent_indices: OnceLock<Vec<usize>>,
|
|
}
|
|
|
|
impl ProcessSnapshot {
|
|
fn new(entries: Vec<WindowsProcessEntry>) -> Self {
|
|
let mut entry_by_pid = HashMap::with_capacity(entries.len());
|
|
let mut children_by_parent = HashMap::<u32, Vec<usize>>::new();
|
|
for (index, entry) in entries.iter().enumerate() {
|
|
entry_by_pid.insert(entry.pid, index);
|
|
children_by_parent
|
|
.entry(entry.parent_pid)
|
|
.or_default()
|
|
.push(index);
|
|
}
|
|
Self {
|
|
entries,
|
|
entry_by_pid,
|
|
children_by_parent,
|
|
agent_indices: OnceLock::new(),
|
|
}
|
|
}
|
|
|
|
fn entry(&self, pid: u32) -> Option<&WindowsProcessEntry> {
|
|
self.entry_by_pid
|
|
.get(&pid)
|
|
.map(|&index| &self.entries[index])
|
|
}
|
|
|
|
fn descendant_signatures(&self, root_pid: u32) -> Vec<ProcessSignature> {
|
|
let mut signatures = descendant_entries(root_pid, self)
|
|
.into_iter()
|
|
.map(ProcessSignature::from_entry)
|
|
.collect::<Vec<_>>();
|
|
signatures.sort_unstable_by_key(|entry| entry.pid);
|
|
signatures
|
|
}
|
|
|
|
fn agent_indices(&self) -> &[usize] {
|
|
self.agent_indices.get_or_init(|| {
|
|
self.entries
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, entry)| process_entry_identifies_agent(entry).then_some(index))
|
|
.collect()
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn raise_server_nofile_limit() {}
|
|
|
|
pub(crate) fn apply_pane_runtime_marker_platform(command: &mut portable_pty::CommandBuilder) {
|
|
if command_uses_git_bash(command) {
|
|
command.env(PANE_RUNTIME_MARKER_ENV_VAR, next_pane_runtime_marker());
|
|
}
|
|
}
|
|
|
|
fn next_pane_runtime_marker() -> String {
|
|
let timestamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_nanos())
|
|
.unwrap_or(0);
|
|
let counter = NEXT_PANE_RUNTIME_MARKER.fetch_add(1, AtomicOrdering::Relaxed);
|
|
format!("{:x}-{timestamp:x}-{counter:x}", std::process::id())
|
|
}
|
|
|
|
fn raw_command_shell(comspec: Option<std::ffi::OsString>) -> std::ffi::OsString {
|
|
comspec
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into())
|
|
}
|
|
|
|
pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option<String> {
|
|
let shell_name = shell_name.to_ascii_lowercase();
|
|
let powershell = shell_name.contains("powershell") || shell_name.contains("pwsh");
|
|
let script = powershell_agent_script(argv)?;
|
|
if powershell {
|
|
Some(script)
|
|
} else {
|
|
Some(cmd_encoded_powershell_command(&script))
|
|
}
|
|
}
|
|
|
|
fn powershell_agent_script(argv: &[String]) -> Option<String> {
|
|
let (program, args) = argv.split_first()?;
|
|
if args.is_empty() {
|
|
return Some(format!("& {}", super::quote_powershell_arg(program)));
|
|
}
|
|
|
|
let powershell_args = args
|
|
.iter()
|
|
.map(|arg| super::quote_powershell_arg(arg))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
let command_line = args
|
|
.iter()
|
|
.map(|arg| super::quote_windows_command_line_arg(arg))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
Some(format!(
|
|
"if((Get-Command {} -ErrorAction SilentlyContinue).CommandType -eq 'ExternalScript'){{& {} {}}}else{{Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait}}",
|
|
super::quote_powershell_arg(program),
|
|
super::quote_powershell_arg(program),
|
|
powershell_args,
|
|
super::quote_powershell_arg(program),
|
|
super::quote_powershell_arg(&command_line),
|
|
))
|
|
}
|
|
|
|
fn cmd_encoded_powershell_command(script: &str) -> String {
|
|
use base64::Engine as _;
|
|
|
|
let utf16 = script
|
|
.encode_utf16()
|
|
.flat_map(u16::to_le_bytes)
|
|
.collect::<Vec<_>>();
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(utf16);
|
|
format!("powershell.exe -NoLogo -NoProfile -EncodedCommand {encoded}")
|
|
}
|
|
|
|
pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command {
|
|
detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec"))
|
|
}
|
|
|
|
pub(crate) fn status_commands_supported() -> bool {
|
|
true
|
|
}
|
|
|
|
pub(crate) fn configure_status_command(process: &mut std::process::Command) {
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
// The process must not run before it is assigned to the kill-on-close job.
|
|
process.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
|
|
}
|
|
|
|
pub(crate) struct StatusCommandGuard {
|
|
job: usize,
|
|
}
|
|
|
|
impl StatusCommandGuard {
|
|
pub(crate) fn new(child: &tokio::process::Child) -> std::io::Result<Self> {
|
|
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
|
if job.is_null() {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
|
|
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
|
|
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
let limits_size = match u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()) {
|
|
Ok(size) => size,
|
|
Err(_) => {
|
|
unsafe {
|
|
CloseHandle(job);
|
|
}
|
|
return Err(std::io::Error::other("job limits size exceeds u32"));
|
|
}
|
|
};
|
|
if unsafe {
|
|
SetInformationJobObject(
|
|
job,
|
|
JobObjectExtendedLimitInformation,
|
|
std::ptr::from_ref(&limits).cast(),
|
|
limits_size,
|
|
)
|
|
} == 0
|
|
{
|
|
let error = std::io::Error::last_os_error();
|
|
unsafe {
|
|
CloseHandle(job);
|
|
}
|
|
return Err(error);
|
|
}
|
|
|
|
let Some(process) = child.raw_handle() else {
|
|
unsafe {
|
|
CloseHandle(job);
|
|
}
|
|
return Err(std::io::Error::other(
|
|
"status command has no process handle",
|
|
));
|
|
};
|
|
if unsafe { AssignProcessToJobObject(job, process.cast()) } == 0 {
|
|
let error = std::io::Error::last_os_error();
|
|
unsafe {
|
|
CloseHandle(job);
|
|
}
|
|
return Err(error);
|
|
}
|
|
if let Err(error) = resume_suspended_process(child.id()) {
|
|
unsafe {
|
|
CloseHandle(job);
|
|
}
|
|
return Err(error);
|
|
}
|
|
|
|
Ok(Self { job: job as usize })
|
|
}
|
|
}
|
|
|
|
fn resume_suspended_process(process_id: Option<u32>) -> std::io::Result<()> {
|
|
let process_id =
|
|
process_id.ok_or_else(|| std::io::Error::other("status command has no process id"))?;
|
|
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
|
|
if snapshot == INVALID_HANDLE_VALUE {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
|
|
let result = (|| {
|
|
let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() };
|
|
entry.dwSize = u32::try_from(size_of::<THREADENTRY32>())
|
|
.map_err(|_| std::io::Error::other("thread entry size exceeds u32"))?;
|
|
if unsafe { Thread32First(snapshot, &mut entry) } == 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
|
|
loop {
|
|
if entry.th32OwnerProcessID == process_id {
|
|
let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
|
|
if thread.is_null() {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let resume_result = unsafe { ResumeThread(thread) };
|
|
let resume_error = (resume_result == u32::MAX).then(std::io::Error::last_os_error);
|
|
unsafe {
|
|
CloseHandle(thread);
|
|
}
|
|
if let Some(error) = resume_error {
|
|
return Err(error);
|
|
}
|
|
return Ok(());
|
|
}
|
|
if unsafe { Thread32Next(snapshot, &mut entry) } == 0 {
|
|
return Err(std::io::Error::other(
|
|
"status command primary thread was not found",
|
|
));
|
|
}
|
|
}
|
|
})();
|
|
|
|
unsafe {
|
|
CloseHandle(snapshot);
|
|
}
|
|
result
|
|
}
|
|
|
|
impl StatusCommandGuard {
|
|
pub(crate) fn terminate(&mut self) {
|
|
if self.job != 0 {
|
|
// KILL_ON_JOB_CLOSE terminates the shell and every descendant still in
|
|
// the job, including on task cancellation and config reload.
|
|
unsafe {
|
|
CloseHandle(self.job as HANDLE);
|
|
}
|
|
self.job = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for StatusCommandGuard {
|
|
fn drop(&mut self) {
|
|
self.terminate();
|
|
}
|
|
}
|
|
|
|
fn detached_custom_command_process_with_comspec(
|
|
command: &str,
|
|
comspec: Option<std::ffi::OsString>,
|
|
) -> std::process::Command {
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
let mut process = std::process::Command::new(raw_command_shell(comspec));
|
|
process.arg("/d").arg("/c").raw_arg(command);
|
|
process
|
|
}
|
|
|
|
pub(crate) fn pane_custom_command_pty_builder_platform(
|
|
command: &str,
|
|
) -> portable_pty::CommandBuilder {
|
|
pane_custom_command_pty_builder_with_comspec(command, std::env::var_os("ComSpec"))
|
|
}
|
|
|
|
fn pane_custom_command_pty_builder_with_comspec(
|
|
command: &str,
|
|
comspec: Option<std::ffi::OsString>,
|
|
) -> portable_pty::CommandBuilder {
|
|
let mut builder = portable_pty::CommandBuilder::new(raw_command_shell(comspec));
|
|
builder.arg("/d");
|
|
builder.arg("/c");
|
|
builder.raw_arg(command);
|
|
builder
|
|
}
|
|
|
|
pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<Vec<String>> {
|
|
let editor = std::env::var("VISUAL")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.or_else(|| {
|
|
std::env::var("EDITOR")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
});
|
|
scrollback_editor_argv_with_env(path, editor.as_deref())
|
|
}
|
|
|
|
fn scrollback_editor_argv_with_env(
|
|
path: &std::path::Path,
|
|
editor: Option<&str>,
|
|
) -> std::io::Result<Vec<String>> {
|
|
let mut argv = match editor.filter(|value| !value.trim().is_empty()) {
|
|
Some(editor) => command_line_to_argv(editor).ok_or_else(|| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
format!("failed to parse editor command {editor:?}"),
|
|
)
|
|
})?,
|
|
None => vec!["notepad.exe".to_string()],
|
|
};
|
|
if argv.is_empty() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"editor command must not be empty",
|
|
));
|
|
}
|
|
argv.push(path.display().to_string());
|
|
Ok(argv)
|
|
}
|
|
|
|
pub(crate) fn configure_background_command_platform(command: &mut std::process::Command) {
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
|
|
pub fn launch_server_daemon_command(command: &mut std::process::Command) -> std::io::Result<u32> {
|
|
if current_job_kills_processes_on_close()? {
|
|
launch_server_daemon_with_wmi(command)
|
|
} else {
|
|
command.spawn().map(|child| child.id())
|
|
}
|
|
}
|
|
|
|
fn launch_server_daemon_with_wmi(command: &std::process::Command) -> std::io::Result<u32> {
|
|
// WMI resolves the class from this Rust type name, including CIM casing.
|
|
#[allow(non_camel_case_types)]
|
|
#[derive(serde::Deserialize)]
|
|
struct Win32_Process;
|
|
|
|
// WMI serializes this embedded object using the matching CIM class name.
|
|
#[allow(non_camel_case_types)]
|
|
#[derive(serde::Serialize)]
|
|
struct Win32_ProcessStartup {
|
|
#[serde(rename = "CreateFlags")]
|
|
create_flags: u32,
|
|
#[serde(rename = "EnvironmentVariables")]
|
|
environment_variables: Vec<String>,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct CreateInput {
|
|
#[serde(rename = "CommandLine")]
|
|
command_line: String,
|
|
#[serde(rename = "CurrentDirectory")]
|
|
current_directory: String,
|
|
#[serde(rename = "ProcessStartupInformation")]
|
|
process_startup_information: Win32_ProcessStartup,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct CreateOutput {
|
|
#[serde(rename = "ProcessId")]
|
|
process_id: Option<u32>,
|
|
#[serde(rename = "ReturnValue")]
|
|
return_value: u32,
|
|
}
|
|
|
|
let current_directory = command
|
|
.get_current_dir()
|
|
.map(std::path::Path::to_path_buf)
|
|
.map(Ok)
|
|
.unwrap_or_else(std::env::current_dir)?;
|
|
let input = CreateInput {
|
|
command_line: windows_command_line(command)?,
|
|
current_directory: unicode_windows_value(
|
|
¤t_directory.into_os_string(),
|
|
"working directory",
|
|
)?,
|
|
process_startup_information: Win32_ProcessStartup {
|
|
create_flags: DETACHED_PROCESS,
|
|
environment_variables: effective_command_environment(command)?,
|
|
},
|
|
};
|
|
|
|
let connection = wmi::WMIConnection::new()
|
|
.map_err(|err| std::io::Error::other(format!("failed to connect to WMI: {err}")))?;
|
|
let output: CreateOutput = connection
|
|
.exec_class_method::<Win32_Process, _>("Create", &input)
|
|
.map_err(|err| std::io::Error::other(format!("WMI Win32_Process.Create failed: {err}")))?;
|
|
if output.return_value != 0 {
|
|
return Err(std::io::Error::other(format!(
|
|
"WMI Win32_Process.Create returned error {}",
|
|
output.return_value
|
|
)));
|
|
}
|
|
output.process_id.ok_or_else(|| {
|
|
std::io::Error::other("WMI Win32_Process.Create succeeded without a process id")
|
|
})
|
|
}
|
|
|
|
fn windows_command_line(command: &std::process::Command) -> std::io::Result<String> {
|
|
std::iter::once(command.get_program())
|
|
.chain(command.get_args())
|
|
.map(|value| {
|
|
unicode_windows_value(value, "server command argument")
|
|
.map(|value| super::quote_windows_command_line_arg(&value))
|
|
})
|
|
.collect::<std::io::Result<Vec<_>>>()
|
|
.map(|parts| parts.join(" "))
|
|
}
|
|
|
|
fn effective_command_environment(command: &std::process::Command) -> std::io::Result<Vec<String>> {
|
|
let mut environment = std::env::vars_os()
|
|
.map(|(key, value)| {
|
|
Ok((
|
|
unicode_windows_value(&key, "inherited environment variable name")?,
|
|
unicode_windows_value(&value, "inherited environment variable value")?,
|
|
))
|
|
})
|
|
.collect::<std::io::Result<Vec<(String, String)>>>()?;
|
|
for (key, value) in command.get_envs() {
|
|
let key = unicode_windows_value(key, "environment variable name")?;
|
|
environment.retain(|(inherited, _)| windows_environment_key_cmp(inherited, &key).is_ne());
|
|
if let Some(value) = value {
|
|
environment.push((
|
|
key,
|
|
unicode_windows_value(value, "environment variable value")?,
|
|
));
|
|
}
|
|
}
|
|
environment.sort_unstable_by(|(left, _), (right, _)| windows_environment_key_cmp(left, right));
|
|
Ok(environment
|
|
.into_iter()
|
|
.map(|(key, value)| format!("{key}={value}"))
|
|
.collect())
|
|
}
|
|
|
|
fn windows_environment_key_cmp(left: &str, right: &str) -> Ordering {
|
|
let left_wide: Vec<u16> = left.encode_utf16().collect();
|
|
let right_wide: Vec<u16> = right.encode_utf16().collect();
|
|
// SAFETY: both pointers remain valid for the call and lengths count UTF-16 units.
|
|
match unsafe {
|
|
CompareStringOrdinal(
|
|
left_wide.as_ptr(),
|
|
left_wide.len() as i32,
|
|
right_wide.as_ptr(),
|
|
right_wide.len() as i32,
|
|
1,
|
|
)
|
|
} {
|
|
CSTR_LESS_THAN => Ordering::Less,
|
|
CSTR_EQUAL => Ordering::Equal,
|
|
CSTR_GREATER_THAN => Ordering::Greater,
|
|
_ => left.cmp(right),
|
|
}
|
|
}
|
|
|
|
fn unicode_windows_value(value: &OsStr, label: &str) -> std::io::Result<String> {
|
|
value.to_str().map(str::to_owned).ok_or_else(|| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
format!("{label} is not valid Unicode"),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn current_process_is_in_job() -> std::io::Result<bool> {
|
|
let mut in_job = 0;
|
|
// SAFETY: `in_job` is a valid writable BOOL for the duration of the call.
|
|
if unsafe { IsProcessInJob(GetCurrentProcess(), null_mut(), &mut in_job) } == 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(in_job != 0)
|
|
}
|
|
|
|
fn current_job_kills_processes_on_close() -> std::io::Result<bool> {
|
|
if !current_process_is_in_job()? {
|
|
return Ok(false);
|
|
}
|
|
|
|
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
|
// SAFETY: `limits` is writable and its exact buffer size is supplied.
|
|
if unsafe {
|
|
QueryInformationJobObject(
|
|
null_mut(),
|
|
JobObjectExtendedLimitInformation,
|
|
&mut limits as *mut _ as *mut c_void,
|
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
null_mut(),
|
|
)
|
|
} == 0
|
|
{
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(limits.BasicLimitInformation.LimitFlags & JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE != 0)
|
|
}
|
|
|
|
pub fn detach_server_daemon_command(command: &mut std::process::Command) {
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
command.creation_flags(DETACHED_PROCESS);
|
|
}
|
|
|
|
pub fn current_process_is_detached_server_daemon() -> bool {
|
|
if !unsafe { GetConsoleWindow() }.is_null() {
|
|
return false;
|
|
}
|
|
|
|
// Job membership alone does not tie the daemon lifetime to its launcher.
|
|
matches!(current_job_kills_processes_on_close(), Ok(false))
|
|
}
|
|
|
|
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
|
|
select_pane_foreground_job_cached(child_pid)
|
|
}
|
|
|
|
pub(crate) fn available_pane_shell(child_pid: u32) -> Option<String> {
|
|
let snapshot = ProcessSnapshot::new(snapshot_processes());
|
|
available_pane_shell_from_snapshot(child_pid, &snapshot)
|
|
}
|
|
|
|
fn available_pane_shell_from_snapshot(
|
|
child_pid: u32,
|
|
snapshot: &ProcessSnapshot,
|
|
) -> Option<String> {
|
|
let shell = snapshot.entry(child_pid)?;
|
|
if !super::is_pane_shell_process_name(&shell.name) {
|
|
return None;
|
|
}
|
|
descendant_entries(child_pid, snapshot)
|
|
.is_empty()
|
|
.then(|| shell.name.clone())
|
|
}
|
|
|
|
pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> {
|
|
let snapshot = cached_foreground_processes();
|
|
let entry = snapshot.entry(process_group_id)?;
|
|
Some(ForegroundJob {
|
|
process_group_id,
|
|
processes: vec![foreground_process_from_entry(entry)],
|
|
})
|
|
}
|
|
|
|
pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
|
|
select_pane_foreground_job_cached(child_pid).map(|job| job.process_group_id)
|
|
}
|
|
|
|
pub fn process_cwd(pid: u32) -> Option<PathBuf> {
|
|
let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?;
|
|
process_cwd_from_handle(process.0)
|
|
}
|
|
|
|
pub(crate) fn pane_process_cwd(child_pid: u32) -> Option<PathBuf> {
|
|
let process = ProcessHandle::open(
|
|
child_pid,
|
|
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
|
|
)?;
|
|
let executable = PathBuf::from(process_executable_path(process.0)?);
|
|
if executable.with_extension("shim").is_file() {
|
|
// A Scoop shim keeps its launch cwd while the real shell can change directories.
|
|
// Never replace the saved shell directory with that launcher directory on exit.
|
|
let snapshot = cached_foreground_processes();
|
|
return process_cwd(shim_shell_pid(child_pid, &snapshot)?);
|
|
}
|
|
process_cwd_from_handle(process.0)
|
|
}
|
|
|
|
fn process_cwd_from_handle(process: HANDLE) -> Option<PathBuf> {
|
|
read_unicode_string(process, process_cwd_descriptor(process)?)
|
|
.map(PathBuf::from)
|
|
.filter(|path| path.is_absolute())
|
|
}
|
|
|
|
fn process_cwd_descriptor(process: HANDLE) -> Option<UNICODE_STRING> {
|
|
#[cfg(target_pointer_width = "64")]
|
|
{
|
|
let mut peb32_address = 0_usize;
|
|
// SAFETY: the output is a writable ULONG_PTR with its exact buffer size.
|
|
let status = unsafe {
|
|
NtQueryInformationProcess(
|
|
process,
|
|
ProcessWow64Information,
|
|
(&mut peb32_address as *mut usize).cast(),
|
|
size_of::<usize>() as u32,
|
|
null_mut(),
|
|
)
|
|
};
|
|
if status != STATUS_SUCCESS as NTSTATUS {
|
|
return None;
|
|
}
|
|
if peb32_address != 0 {
|
|
// WoW64's native PEB can report C:\Windows while the x86 shell uses
|
|
// another directory. Read its own pointer-width layout instead.
|
|
let peb = read_process_value::<Peb32>(process, peb32_address as *const c_void)?;
|
|
let parameters = read_process_value::<ProcessCwdParameters32>(
|
|
process,
|
|
peb.process_parameters as usize as *const c_void,
|
|
)?;
|
|
let cwd = parameters.current_directory;
|
|
return Some(UNICODE_STRING {
|
|
Length: cwd.Length,
|
|
MaximumLength: cwd.MaximumLength,
|
|
Buffer: cwd.Buffer as usize as *mut u16,
|
|
});
|
|
}
|
|
}
|
|
Some(read_process_parameters(process)?.current_directory.dos_path)
|
|
}
|
|
|
|
fn shim_shell_pid(child_pid: u32, snapshot: &ProcessSnapshot) -> Option<u32> {
|
|
snapshot.entry(child_pid)?;
|
|
let mut shells = snapshot
|
|
.children_by_parent
|
|
.get(&child_pid)?
|
|
.iter()
|
|
.filter_map(|&index| {
|
|
let entry = &snapshot.entries[index];
|
|
super::is_pane_shell_process_name(&entry.name).then_some(entry.pid)
|
|
});
|
|
let shell_pid = shells.next()?;
|
|
shells.next().is_none().then_some(shell_pid)
|
|
}
|
|
|
|
fn select_pane_foreground_job_cached(shell_pid: u32) -> Option<ForegroundJob> {
|
|
let snapshot = cached_foreground_processes();
|
|
let (job, retry_with_fresh_snapshot) =
|
|
select_pane_foreground_job_from_snapshot(shell_pid, &snapshot)?;
|
|
if !retry_with_fresh_snapshot {
|
|
return Some(job);
|
|
}
|
|
|
|
let snapshot = fresh_foreground_processes();
|
|
select_pane_foreground_job_from_snapshot(shell_pid, &snapshot).map(|(job, _)| job)
|
|
}
|
|
|
|
fn select_pane_foreground_job_from_snapshot(
|
|
shell_pid: u32,
|
|
snapshot: &ProcessSnapshot,
|
|
) -> Option<(ForegroundJob, bool)> {
|
|
if let Some(job) = FOREGROUND_SELECTION_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner())
|
|
.get(shell_pid, snapshot)
|
|
{
|
|
return Some((job, false));
|
|
}
|
|
|
|
let job = select_pane_foreground_job_from_snapshot_uncached(shell_pid, snapshot)?;
|
|
let cached = prepare_cached_foreground_selection(shell_pid, snapshot, &job);
|
|
let retry_with_fresh_snapshot = job.process_group_id != shell_pid && cached.is_none();
|
|
FOREGROUND_SELECTION_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner())
|
|
.remember(shell_pid, cached);
|
|
Some((job, retry_with_fresh_snapshot))
|
|
}
|
|
|
|
fn select_pane_foreground_job_from_snapshot_uncached(
|
|
shell_pid: u32,
|
|
snapshot: &ProcessSnapshot,
|
|
) -> Option<ForegroundJob> {
|
|
select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
shell_pid,
|
|
snapshot,
|
|
|shell| process_is_git_bash(shell.pid),
|
|
|entry| process_runtime_marker(entry.pid),
|
|
)
|
|
}
|
|
|
|
fn select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
shell_pid: u32,
|
|
snapshot: &ProcessSnapshot,
|
|
shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool,
|
|
mut runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option<String>,
|
|
) -> Option<ForegroundJob> {
|
|
let entries = &snapshot.entries;
|
|
let shell = snapshot.entry(shell_pid)?;
|
|
let descendants = descendant_entries(shell_pid, snapshot);
|
|
let mut candidates = Vec::new();
|
|
for entry in std::iter::once(shell).chain(descendants) {
|
|
if process_entry_identifies_agent(entry) {
|
|
candidates.push(entry);
|
|
}
|
|
}
|
|
|
|
if let Some(selected) = select_topmost_agent_chain_candidate(&candidates, snapshot) {
|
|
return Some(foreground_job_from_entry(selected));
|
|
}
|
|
if !candidates.is_empty() || !shell_is_git_bash(shell) {
|
|
return Some(foreground_job_from_entry(shell));
|
|
}
|
|
|
|
let escaped_agent_indices = snapshot.agent_indices();
|
|
if escaped_agent_indices.is_empty() {
|
|
return Some(foreground_job_from_entry(shell));
|
|
}
|
|
|
|
let Some(shell_runtime_marker) = pane_runtime_marker(shell, &mut runtime_marker) else {
|
|
return Some(foreground_job_from_entry(shell));
|
|
};
|
|
let matching_candidates: Vec<_> = escaped_agent_indices
|
|
.iter()
|
|
.map(|&index| &entries[index])
|
|
.filter(|entry| {
|
|
carries_pane_runtime_marker(entry, &shell_runtime_marker, &mut runtime_marker)
|
|
})
|
|
.collect();
|
|
let selected =
|
|
select_topmost_agent_chain_candidate(&matching_candidates, snapshot).unwrap_or(shell);
|
|
Some(foreground_job_from_entry(selected))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn select_pane_foreground_job(
|
|
shell_pid: u32,
|
|
entries: &[WindowsProcessEntry],
|
|
) -> Option<ForegroundJob> {
|
|
select_pane_foreground_job_from_snapshot_uncached(
|
|
shell_pid,
|
|
&ProcessSnapshot::new(entries.to_vec()),
|
|
)
|
|
}
|
|
|
|
/// Git Bash can start agents outside the pane shell's process tree. Those
|
|
/// belong to the pane when they carry the runtime marker the shell got.
|
|
fn pane_runtime_marker(
|
|
shell: &WindowsProcessEntry,
|
|
runtime_marker: &mut impl FnMut(&WindowsProcessEntry) -> Option<String>,
|
|
) -> Option<String> {
|
|
runtime_marker(shell).filter(|marker| !marker.is_empty())
|
|
}
|
|
|
|
fn carries_pane_runtime_marker(
|
|
entry: &WindowsProcessEntry,
|
|
pane_marker: &str,
|
|
runtime_marker: &mut impl FnMut(&WindowsProcessEntry) -> Option<String>,
|
|
) -> bool {
|
|
runtime_marker(entry).as_deref() == Some(pane_marker)
|
|
}
|
|
|
|
/// Whether foreground selection could pick `pid` for this pane: a descendant
|
|
/// of the pane shell, or an escaped Git Bash agent with the pane's marker.
|
|
fn process_belongs_to_pane(
|
|
shell_pid: u32,
|
|
pid: u32,
|
|
snapshot: &ProcessSnapshot,
|
|
shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool,
|
|
mut runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option<String>,
|
|
) -> bool {
|
|
if process_is_ancestor(shell_pid, pid, snapshot) {
|
|
return true;
|
|
}
|
|
let (Some(shell), Some(entry)) = (snapshot.entry(shell_pid), snapshot.entry(pid)) else {
|
|
return false;
|
|
};
|
|
shell_is_git_bash(shell)
|
|
&& process_entry_identifies_agent(entry)
|
|
&& pane_runtime_marker(shell, &mut runtime_marker).is_some_and(|pane_marker| {
|
|
carries_pane_runtime_marker(entry, &pane_marker, &mut runtime_marker)
|
|
})
|
|
}
|
|
|
|
/// Creation time of `pid`. It tells a process apart from a later one that
|
|
/// reuses its pid.
|
|
pub fn process_start_token(pid: u32) -> Option<u64> {
|
|
ProcessIdentity::open(pid)?.creation_time()
|
|
}
|
|
|
|
/// Returns `pid` while that same process, matched by its creation time, is
|
|
/// still running for the pane shell `shell_pid`. Windows has no job control,
|
|
/// so the process stands in for its own group.
|
|
pub fn live_pane_process_group(shell_pid: u32, pid: u32, start_token: u64) -> Option<u32> {
|
|
let identity = ProcessIdentity::open(pid)?;
|
|
if !identity.running() || identity.creation_time() != Some(start_token) {
|
|
return None;
|
|
}
|
|
process_belongs_to_pane(
|
|
shell_pid,
|
|
pid,
|
|
&cached_foreground_processes(),
|
|
|shell| process_is_git_bash(shell.pid),
|
|
|entry| process_runtime_marker(entry.pid),
|
|
)
|
|
.then_some(pid)
|
|
}
|
|
|
|
fn process_entry_identifies_agent(entry: &WindowsProcessEntry) -> bool {
|
|
crate::detect::identify_agent(&entry.name).is_some()
|
|
|| crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some()
|
|
}
|
|
|
|
fn foreground_job_from_entry(entry: &WindowsProcessEntry) -> ForegroundJob {
|
|
ForegroundJob {
|
|
process_group_id: entry.pid,
|
|
processes: vec![foreground_process_from_entry(entry)],
|
|
}
|
|
}
|
|
|
|
fn select_topmost_agent_chain_candidate<'a>(
|
|
candidates: &[&'a WindowsProcessEntry],
|
|
snapshot: &ProcessSnapshot,
|
|
) -> Option<&'a WindowsProcessEntry> {
|
|
if candidates.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
candidates.iter().copied().find(|entry| {
|
|
candidates.iter().all(|other| {
|
|
entry.pid == other.pid || process_is_ancestor(entry.pid, other.pid, snapshot)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn process_is_ancestor(ancestor_pid: u32, descendant_pid: u32, snapshot: &ProcessSnapshot) -> bool {
|
|
let mut current = descendant_pid;
|
|
let mut visited = HashSet::new();
|
|
while visited.insert(current) {
|
|
let Some(parent) = snapshot.entry(current).map(|entry| entry.parent_pid) else {
|
|
return false;
|
|
};
|
|
if parent == ancestor_pid {
|
|
return true;
|
|
}
|
|
if parent == 0 {
|
|
return false;
|
|
}
|
|
current = parent;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn descendant_entries(root_pid: u32, snapshot: &ProcessSnapshot) -> Vec<&WindowsProcessEntry> {
|
|
let mut output = Vec::new();
|
|
let mut queue = VecDeque::new();
|
|
let mut visited = HashSet::new();
|
|
visited.insert(root_pid);
|
|
if let Some(root_children) = snapshot.children_by_parent.get(&root_pid) {
|
|
for &index in root_children {
|
|
let entry = &snapshot.entries[index];
|
|
if visited.insert(entry.pid) {
|
|
queue.push_back(entry);
|
|
}
|
|
}
|
|
}
|
|
while let Some(entry) = queue.pop_front() {
|
|
output.push(entry);
|
|
if let Some(next) = snapshot.children_by_parent.get(&entry.pid) {
|
|
for &index in next {
|
|
let child = &snapshot.entries[index];
|
|
if visited.insert(child.pid) {
|
|
queue.push_back(child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
output
|
|
}
|
|
|
|
fn foreground_process_from_entry(entry: &WindowsProcessEntry) -> super::ForegroundProcess {
|
|
let command = entry.command();
|
|
super::ForegroundProcess {
|
|
pid: entry.pid,
|
|
name: entry.name.clone(),
|
|
argv0: command.argv0.clone(),
|
|
argv: command.argv.clone(),
|
|
cmdline: command.cmdline.clone(),
|
|
}
|
|
}
|
|
|
|
fn snapshot_processes() -> Vec<WindowsProcessEntry> {
|
|
#[cfg(test)]
|
|
PROCESS_INSPECTION_COUNTS.with_borrow_mut(|counts| counts.snapshots += 1);
|
|
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
|
if snapshot == INVALID_HANDLE_VALUE {
|
|
return Vec::new();
|
|
}
|
|
let _snapshot = ProcessHandle(snapshot);
|
|
|
|
let mut entry = PROCESSENTRY32W {
|
|
dwSize: size_of::<PROCESSENTRY32W>() as u32,
|
|
..Default::default()
|
|
};
|
|
let mut output = Vec::new();
|
|
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
|
|
while ok {
|
|
let pid = entry.th32ProcessID;
|
|
let name = nul_terminated_utf16_to_string(&entry.szExeFile);
|
|
output.push(WindowsProcessEntry {
|
|
pid,
|
|
parent_pid: entry.th32ParentProcessID,
|
|
name,
|
|
command: OnceLock::new(),
|
|
});
|
|
ok = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
|
|
}
|
|
output
|
|
}
|
|
|
|
fn cached_foreground_processes() -> Arc<ProcessSnapshot> {
|
|
let mut cache = FOREGROUND_PROCESS_SNAPSHOT_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner());
|
|
cache.snapshot(FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL, snapshot_processes)
|
|
}
|
|
|
|
fn fresh_foreground_processes() -> Arc<ProcessSnapshot> {
|
|
let mut cache = FOREGROUND_PROCESS_SNAPSHOT_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner());
|
|
cache.snapshot(Duration::ZERO, snapshot_processes)
|
|
}
|
|
|
|
fn prepare_cached_foreground_selection(
|
|
shell_pid: u32,
|
|
snapshot: &ProcessSnapshot,
|
|
job: &ForegroundJob,
|
|
) -> Option<CachedForegroundSelection> {
|
|
if !CachedForegroundSelection::can_cache(shell_pid, snapshot, job) {
|
|
return None;
|
|
}
|
|
let shell_identity = ProcessIdentity::open(shell_pid)?;
|
|
let selected_identity = ProcessIdentity::open(job.process_group_id)?;
|
|
let descendants = snapshot.descendant_signatures(shell_pid);
|
|
let descendant_identities = descendants
|
|
.iter()
|
|
.map(|entry| ProcessIdentity::open(entry.pid))
|
|
.collect::<Option<Vec<_>>>()?;
|
|
CachedForegroundSelection::from_snapshot_with_identities(
|
|
shell_pid,
|
|
snapshot,
|
|
job,
|
|
descendants,
|
|
descendant_identities,
|
|
shell_identity,
|
|
selected_identity,
|
|
)
|
|
}
|
|
|
|
impl CachedForegroundSelection {
|
|
fn can_cache(shell_pid: u32, snapshot: &ProcessSnapshot, job: &ForegroundJob) -> bool {
|
|
// Idle native shells cannot use Git Bash's escaped-agent fallback.
|
|
// Keep reevaluating unknown children; a first child invalidates topology.
|
|
job.process_group_id != shell_pid
|
|
|| (snapshot.entry(shell_pid).is_some_and(|shell| {
|
|
["cmd.exe", "powershell.exe", "pwsh.exe"]
|
|
.iter()
|
|
.any(|name| shell.name.eq_ignore_ascii_case(name))
|
|
}) && !snapshot.children_by_parent.contains_key(&shell_pid))
|
|
}
|
|
|
|
fn from_snapshot_with_identities(
|
|
shell_pid: u32,
|
|
snapshot: &ProcessSnapshot,
|
|
job: &ForegroundJob,
|
|
descendants: Vec<ProcessSignature>,
|
|
descendant_identities: Vec<ProcessIdentity>,
|
|
shell_identity: ProcessIdentity,
|
|
selected_identity: ProcessIdentity,
|
|
) -> Option<Self> {
|
|
if !Self::can_cache(shell_pid, snapshot, job) {
|
|
return None;
|
|
}
|
|
let shell_entry = snapshot.entry(shell_pid)?;
|
|
if shell_identity.creation_time() != shell_entry.command().creation_time {
|
|
return None;
|
|
}
|
|
let shell = ProcessSignature::from_entry(shell_entry);
|
|
let selected_entry = snapshot.entry(job.process_group_id)?;
|
|
if selected_identity.creation_time() != selected_entry.command().creation_time {
|
|
return None;
|
|
}
|
|
let selected = ProcessSignature::from_entry(selected_entry);
|
|
let descendants_match_identities = descendants.len() == descendant_identities.len()
|
|
&& descendants
|
|
.iter()
|
|
.zip(&descendant_identities)
|
|
.all(|(signature, identity)| {
|
|
snapshot.entry(signature.pid).is_some_and(|entry| {
|
|
identity.creation_time() == entry.command().creation_time
|
|
})
|
|
});
|
|
if !descendants_match_identities
|
|
|| !shell_identity.running()
|
|
|| !selected_identity.running()
|
|
|| !descendant_identities.iter().all(ProcessIdentity::running)
|
|
{
|
|
return None;
|
|
}
|
|
let now = Instant::now();
|
|
Some(Self {
|
|
shell,
|
|
selected,
|
|
descendants,
|
|
descendant_identities,
|
|
shell_identity,
|
|
selected_identity,
|
|
job: job.clone(),
|
|
verified_at: now,
|
|
last_used: now,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ForegroundSelectionCache {
|
|
fn get(&mut self, shell_pid: u32, snapshot: &ProcessSnapshot) -> Option<ForegroundJob> {
|
|
if let Some(cached) = self.entries.get_mut(&shell_pid) {
|
|
let current_descendants = descendant_entries(shell_pid, snapshot);
|
|
let topology_matches = current_descendants.len() == cached.descendants.len()
|
|
&& cached
|
|
.descendants
|
|
.iter()
|
|
.all(|entry| entry.matches(snapshot.entry(entry.pid)));
|
|
let valid = cached.verified_at.elapsed() < FOREGROUND_SELECTION_RECHECK
|
|
&& cached.shell_identity.running()
|
|
&& cached.selected_identity.running()
|
|
&& cached
|
|
.descendant_identities
|
|
.iter()
|
|
.all(ProcessIdentity::running)
|
|
&& cached.shell.matches(snapshot.entry(shell_pid))
|
|
&& cached
|
|
.selected
|
|
.matches(snapshot.entry(cached.job.process_group_id))
|
|
&& topology_matches;
|
|
if valid {
|
|
cached.last_used = Instant::now();
|
|
return Some(cached.job.clone());
|
|
}
|
|
}
|
|
self.entries.remove(&shell_pid);
|
|
None
|
|
}
|
|
|
|
fn remember(&mut self, shell_pid: u32, cached: Option<CachedForegroundSelection>) {
|
|
let Some(cached) = cached else {
|
|
self.entries.remove(&shell_pid);
|
|
return;
|
|
};
|
|
self.entries
|
|
.retain(|_, cached| cached.last_used.elapsed() < FOREGROUND_SELECTION_CACHE_RETENTION);
|
|
if self.entries.len() >= FOREGROUND_SELECTION_CACHE_CAPACITY {
|
|
self.entries.clear();
|
|
}
|
|
self.entries.insert(shell_pid, cached);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn remember_for_test(
|
|
&mut self,
|
|
shell_pid: u32,
|
|
snapshot: &ProcessSnapshot,
|
|
job: &ForegroundJob,
|
|
) {
|
|
let descendants = snapshot.descendant_signatures(shell_pid);
|
|
let descendant_identities = descendants
|
|
.iter()
|
|
.map(|_| ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: None,
|
|
})
|
|
.collect();
|
|
let cached = CachedForegroundSelection::from_snapshot_with_identities(
|
|
shell_pid,
|
|
snapshot,
|
|
job,
|
|
descendants,
|
|
descendant_identities,
|
|
ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: None,
|
|
},
|
|
ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: None,
|
|
},
|
|
);
|
|
self.remember(shell_pid, cached);
|
|
}
|
|
}
|
|
|
|
impl ProcessSnapshotCache {
|
|
fn snapshot(
|
|
&mut self,
|
|
max_age: Duration,
|
|
build: impl FnOnce() -> Vec<WindowsProcessEntry>,
|
|
) -> Arc<ProcessSnapshot> {
|
|
if let Some(cached) = &self.cached {
|
|
if cached.built_at.elapsed() < max_age {
|
|
return Arc::clone(&cached.snapshot);
|
|
}
|
|
}
|
|
|
|
let snapshot = Arc::new(ProcessSnapshot::new(build()));
|
|
self.cached = Some(CachedProcessSnapshot {
|
|
built_at: Instant::now(),
|
|
snapshot: Arc::clone(&snapshot),
|
|
});
|
|
snapshot
|
|
}
|
|
}
|
|
|
|
fn read_process_command(pid: u32, name: &str) -> WindowsProcessCommand {
|
|
// Prefer the command-line information class: it needs only
|
|
// `PROCESS_QUERY_LIMITED_INFORMATION`, while the PEB path below also needs
|
|
// `PROCESS_VM_READ`, which hardened runtimes (Electron/Node) and security
|
|
// products deny. Without a command line an agent launched through a runtime
|
|
// is indistinguishable from a bare `node.exe`/`bun.exe` process, so the
|
|
// pane would never register as an agent.
|
|
if let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) {
|
|
if let Some(cmdline) = read_process_command_line(process.0) {
|
|
let creation_time = process_creation_time(process.0);
|
|
return WindowsProcessCommand::from_cmdline(name, creation_time, Some(cmdline));
|
|
}
|
|
}
|
|
|
|
let Some(process) =
|
|
ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)
|
|
else {
|
|
let creation_time = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION)
|
|
.and_then(|process| process_creation_time(process.0));
|
|
return WindowsProcessCommand::from_cmdline(name, creation_time, None);
|
|
};
|
|
let creation_time = process_creation_time(process.0);
|
|
let cmdline = read_process_parameters(process.0)
|
|
.and_then(|parameters| read_unicode_string(process.0, parameters.command_line));
|
|
WindowsProcessCommand::from_cmdline(name, creation_time, cmdline)
|
|
}
|
|
|
|
impl WindowsProcessCommand {
|
|
fn from_cmdline(name: &str, creation_time: Option<u64>, cmdline: Option<String>) -> Self {
|
|
let argv = cmdline.as_deref().and_then(command_line_to_argv);
|
|
let argv0 = argv
|
|
.as_ref()
|
|
.and_then(|argv| argv.first().cloned())
|
|
.or_else(|| (!name.is_empty()).then(|| name.to_string()));
|
|
Self {
|
|
creation_time,
|
|
argv0,
|
|
argv,
|
|
cmdline,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn process_is_git_bash(pid: u32) -> bool {
|
|
let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
|
|
return false;
|
|
};
|
|
let Some(creation_time) = process_creation_time(process.0) else {
|
|
return false;
|
|
};
|
|
{
|
|
let mut cache = GIT_BASH_PROCESS_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner());
|
|
if let Some(cached) = cache.get_mut(&pid) {
|
|
if cached.creation_time == creation_time {
|
|
cached.last_used = Instant::now();
|
|
return cached.is_git_bash;
|
|
}
|
|
}
|
|
}
|
|
|
|
let is_git_bash = process_executable_path(process.0)
|
|
.as_deref()
|
|
.is_some_and(|path| is_git_bash_executable_path(std::path::Path::new(path)));
|
|
let mut cache = GIT_BASH_PROCESS_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner());
|
|
if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY {
|
|
cache.retain(|_, cached| {
|
|
cached.last_used.elapsed() < PROCESS_RUNTIME_MARKER_CACHE_RETENTION
|
|
});
|
|
if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY {
|
|
cache.clear();
|
|
}
|
|
}
|
|
cache.insert(
|
|
pid,
|
|
CachedGitBashProcess {
|
|
creation_time,
|
|
is_git_bash,
|
|
last_used: Instant::now(),
|
|
},
|
|
);
|
|
is_git_bash
|
|
}
|
|
|
|
fn process_executable_path(process: HANDLE) -> Option<String> {
|
|
let mut path = vec![0_u16; 32_768];
|
|
let mut len = path.len() as u32;
|
|
if unsafe { QueryFullProcessImageNameW(process, 0, path.as_mut_ptr(), &mut len) } == 0 {
|
|
return None;
|
|
}
|
|
String::from_utf16(&path[..len as usize]).ok()
|
|
}
|
|
|
|
fn command_uses_git_bash(command: &portable_pty::CommandBuilder) -> bool {
|
|
let Some(program) = command.get_argv().first() else {
|
|
return false;
|
|
};
|
|
let path = std::path::Path::new(program);
|
|
if path.is_absolute() {
|
|
return is_git_bash_executable_path(path);
|
|
}
|
|
if program.to_string_lossy().contains(['/', '\\']) {
|
|
return false;
|
|
}
|
|
|
|
let Some(file_name) = path.file_name().and_then(OsStr::to_str) else {
|
|
return false;
|
|
};
|
|
let candidate_name = if file_name.eq_ignore_ascii_case("bash") {
|
|
"bash.exe"
|
|
} else if file_name.eq_ignore_ascii_case("bash.exe") {
|
|
file_name
|
|
} else {
|
|
return false;
|
|
};
|
|
let search_path = command
|
|
.get_env("PATH")
|
|
.map(OsStr::to_os_string)
|
|
.or_else(|| std::env::var_os("PATH"));
|
|
search_path.is_some_and(|search_path| {
|
|
std::env::split_paths(&search_path)
|
|
.map(|directory| directory.join(candidate_name))
|
|
.find(|candidate| candidate.is_file())
|
|
.is_some_and(|candidate| is_git_bash_executable_path(&candidate))
|
|
})
|
|
}
|
|
|
|
fn is_git_bash_executable_path(path: &std::path::Path) -> bool {
|
|
let Some(file_name) = path.file_name().and_then(OsStr::to_str) else {
|
|
return false;
|
|
};
|
|
if !file_name.eq_ignore_ascii_case("bash.exe") || !path.is_absolute() || !path.is_file() {
|
|
return false;
|
|
}
|
|
|
|
let Some(bin_dir) = path.parent() else {
|
|
return false;
|
|
};
|
|
if !bin_dir
|
|
.file_name()
|
|
.and_then(OsStr::to_str)
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("bin"))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let Some(mut root) = bin_dir.parent() else {
|
|
return false;
|
|
};
|
|
if root
|
|
.file_name()
|
|
.and_then(OsStr::to_str)
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("usr"))
|
|
{
|
|
let Some(parent) = root.parent() else {
|
|
return false;
|
|
};
|
|
root = parent;
|
|
}
|
|
|
|
root.join("usr").join("bin").join("msys-2.0.dll").is_file()
|
|
&& root.join("cmd").join("git.exe").is_file()
|
|
}
|
|
|
|
fn process_runtime_marker(pid: u32) -> Option<String> {
|
|
let process = ProcessHandle::open(pid, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)?;
|
|
let creation_time = process_creation_time(process.0)?;
|
|
{
|
|
let mut cache = PROCESS_RUNTIME_MARKER_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner());
|
|
if let Some(cached) = cache.get_mut(&pid) {
|
|
if cached.creation_time == creation_time
|
|
&& (cached.marker.is_some()
|
|
|| cached.cached_at.elapsed() < PROCESS_RUNTIME_MARKER_NEGATIVE_TTL)
|
|
{
|
|
cached.last_used = Instant::now();
|
|
return cached.marker.clone();
|
|
}
|
|
}
|
|
}
|
|
|
|
let marker = process_runtime_marker_from_handle(process.0)?;
|
|
let mut cache = PROCESS_RUNTIME_MARKER_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|err| err.into_inner());
|
|
if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY {
|
|
cache.retain(|_, cached| {
|
|
cached.last_used.elapsed() < PROCESS_RUNTIME_MARKER_CACHE_RETENTION
|
|
});
|
|
if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY {
|
|
cache.clear();
|
|
}
|
|
}
|
|
cache.insert(
|
|
pid,
|
|
CachedProcessRuntimeMarker {
|
|
creation_time,
|
|
marker: marker.clone(),
|
|
cached_at: Instant::now(),
|
|
last_used: Instant::now(),
|
|
},
|
|
);
|
|
marker
|
|
}
|
|
|
|
fn process_creation_time(process: HANDLE) -> Option<u64> {
|
|
let mut creation_time = FILETIME::default();
|
|
let mut exit_time = FILETIME::default();
|
|
let mut kernel_time = FILETIME::default();
|
|
let mut user_time = FILETIME::default();
|
|
if unsafe {
|
|
GetProcessTimes(
|
|
process,
|
|
&mut creation_time,
|
|
&mut exit_time,
|
|
&mut kernel_time,
|
|
&mut user_time,
|
|
)
|
|
} == 0
|
|
{
|
|
return None;
|
|
}
|
|
Some((u64::from(creation_time.dwHighDateTime) << 32) | u64::from(creation_time.dwLowDateTime))
|
|
}
|
|
|
|
fn process_runtime_marker_from_handle(process: HANDLE) -> Option<Option<String>> {
|
|
let parameters = read_process_parameters(process)?;
|
|
let environment = read_process_environment(process, parameters.environment)?;
|
|
Some(environment_variable_from_utf16(
|
|
&environment,
|
|
PANE_RUNTIME_MARKER_ENV_VAR,
|
|
))
|
|
}
|
|
|
|
fn read_process_environment(process: HANDLE, address: *const c_void) -> Option<Vec<u16>> {
|
|
if address.is_null() {
|
|
return None;
|
|
}
|
|
|
|
let mut memory = MaybeUninit::<MEMORY_BASIC_INFORMATION>::uninit();
|
|
let queried = unsafe {
|
|
VirtualQueryEx(
|
|
process,
|
|
address,
|
|
memory.as_mut_ptr(),
|
|
size_of::<MEMORY_BASIC_INFORMATION>(),
|
|
)
|
|
};
|
|
if queried == 0 {
|
|
return None;
|
|
}
|
|
let memory = unsafe { memory.assume_init() };
|
|
let address = address as usize;
|
|
let base = memory.BaseAddress as usize;
|
|
let offset = address.checked_sub(base)?;
|
|
let available = memory.RegionSize.checked_sub(offset)?;
|
|
let read_len = available.min(MAX_PROCESS_ENVIRONMENT_BYTES);
|
|
if read_len < size_of::<u16>() {
|
|
return None;
|
|
}
|
|
|
|
let max_units = read_len / size_of::<u16>();
|
|
let chunk_units = PROCESS_ENVIRONMENT_READ_CHUNK_BYTES / size_of::<u16>();
|
|
let mut environment = Vec::new();
|
|
while environment.len() < max_units {
|
|
let unit_count = (max_units - environment.len()).min(chunk_units);
|
|
let chunk_bytes = unit_count * size_of::<u16>();
|
|
let mut chunk = vec![0_u16; unit_count];
|
|
let mut bytes_read = 0;
|
|
let offset = environment.len().checked_mul(size_of::<u16>())?;
|
|
let chunk_address = address.checked_add(offset)?;
|
|
if unsafe {
|
|
ReadProcessMemory(
|
|
process,
|
|
chunk_address as *const c_void,
|
|
chunk.as_mut_ptr().cast::<c_void>(),
|
|
chunk_bytes,
|
|
&mut bytes_read,
|
|
)
|
|
} == 0
|
|
{
|
|
break;
|
|
}
|
|
chunk.truncate(bytes_read / size_of::<u16>());
|
|
if chunk.is_empty() {
|
|
break;
|
|
}
|
|
environment.extend_from_slice(&chunk);
|
|
if let Some(end) = environment
|
|
.windows(2)
|
|
.position(|pair| pair == [0, 0])
|
|
.map(|index| index + 2)
|
|
{
|
|
environment.truncate(end);
|
|
return Some(environment);
|
|
}
|
|
if bytes_read < chunk_bytes {
|
|
break;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn environment_variable_from_utf16(environment: &[u16], name: &str) -> Option<String> {
|
|
for variable in environment.split(|unit| *unit == 0) {
|
|
if variable.is_empty() {
|
|
break;
|
|
}
|
|
let Some(separator) = variable.iter().position(|unit| *unit == u16::from(b'=')) else {
|
|
continue;
|
|
};
|
|
let Ok(variable_name) = String::from_utf16(&variable[..separator]) else {
|
|
continue;
|
|
};
|
|
if variable_name.eq_ignore_ascii_case(name) {
|
|
return String::from_utf16(&variable[separator + 1..]).ok();
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Read a process command line with only `PROCESS_QUERY_LIMITED_INFORMATION`.
|
|
///
|
|
/// `ProcessCommandLineInformation` has been available since Windows 8.1.
|
|
/// Prefer it over walking the target PEB, which additionally requires
|
|
/// `PROCESS_VM_READ` access that hardened runtimes and security products deny.
|
|
///
|
|
/// Returns `None` for a process without a stored command line; the caller then
|
|
/// tries the PEB path before giving up.
|
|
fn read_process_command_line(process: HANDLE) -> Option<String> {
|
|
#[cfg(test)]
|
|
PROCESS_INSPECTION_COUNTS.with_borrow_mut(|counts| counts.command_reads += 1);
|
|
let mut required = 0_u32;
|
|
// SAFETY: a null buffer with length 0 only asks for the required size, and
|
|
// `required` is a valid out-pointer for the duration of the call.
|
|
let status = unsafe {
|
|
NtQueryInformationProcess(
|
|
process,
|
|
ProcessCommandLineInformation,
|
|
null_mut(),
|
|
0,
|
|
&mut required,
|
|
)
|
|
};
|
|
// A process with no command line is already handled as a miss below.
|
|
if status != STATUS_BUFFER_TOO_SMALL
|
|
&& status != STATUS_INFO_LENGTH_MISMATCH
|
|
&& status != STATUS_BUFFER_OVERFLOW
|
|
{
|
|
return None;
|
|
}
|
|
|
|
// `required` is already at least a UNICODE_STRING sized buffer.
|
|
let mut buffer = vec![0_u8; required as usize];
|
|
for _ in 0..2 {
|
|
// SAFETY: `buffer` is `required` bytes and both pointers are valid for
|
|
// the call; the kernel writes the length back into `required`.
|
|
let status = unsafe {
|
|
NtQueryInformationProcess(
|
|
process,
|
|
ProcessCommandLineInformation,
|
|
buffer.as_mut_ptr().cast(),
|
|
required,
|
|
&mut required,
|
|
)
|
|
};
|
|
// These three statuses all mean the command line grew between the probe
|
|
// and the read. Some data was written; retry once with the larger buffer
|
|
// the call just reported. They are negative as `NTSTATUS`, so they must
|
|
// be checked before the failure test below.
|
|
let grew = status == STATUS_BUFFER_OVERFLOW
|
|
|| status == STATUS_BUFFER_TOO_SMALL
|
|
|| status == STATUS_INFO_LENGTH_MISMATCH;
|
|
if grew {
|
|
buffer = vec![0_u8; required as usize];
|
|
continue;
|
|
}
|
|
if status < 0 {
|
|
return None;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// SAFETY: on success the kernel wrote a UNICODE_STRING followed by its
|
|
// UTF-16 contents into `buffer`. A `Vec<u8>` only guarantees byte
|
|
// alignment, so read the header unaligned.
|
|
let unicode = unsafe { buffer.as_ptr().cast::<UNICODE_STRING>().read_unaligned() };
|
|
let length = usize::from(unicode.Length);
|
|
// A short command line leaves `Length` inside the header itself; guard
|
|
// against reading a malformed header as string data.
|
|
if length == 0 || !length.is_multiple_of(2) {
|
|
return None;
|
|
}
|
|
let string_offset = size_of::<UNICODE_STRING>();
|
|
if string_offset + length > buffer.len() {
|
|
return None;
|
|
}
|
|
let units = buffer[string_offset..string_offset + length]
|
|
.chunks_exact(2)
|
|
.map(|unit| u16::from_ne_bytes([unit[0], unit[1]]))
|
|
.collect::<Vec<_>>();
|
|
String::from_utf16(&units)
|
|
.ok()
|
|
.filter(|command_line| !command_line.is_empty())
|
|
}
|
|
|
|
fn read_process_parameters(process: HANDLE) -> Option<RtlUserProcessParameters> {
|
|
let mut basic_info = MaybeUninit::<PROCESS_BASIC_INFORMATION>::uninit();
|
|
let status = unsafe {
|
|
NtQueryInformationProcess(
|
|
process,
|
|
ProcessBasicInformation,
|
|
basic_info.as_mut_ptr().cast::<c_void>(),
|
|
size_of::<PROCESS_BASIC_INFORMATION>() as u32,
|
|
null_mut(),
|
|
)
|
|
};
|
|
if status != STATUS_SUCCESS as NTSTATUS {
|
|
return None;
|
|
}
|
|
|
|
let basic_info = unsafe { basic_info.assume_init() };
|
|
if basic_info.PebBaseAddress.is_null() {
|
|
return None;
|
|
}
|
|
|
|
let peb = read_process_value::<Peb>(process, basic_info.PebBaseAddress.cast::<c_void>())?;
|
|
if peb.process_parameters.is_null() {
|
|
return None;
|
|
}
|
|
|
|
read_process_value::<RtlUserProcessParameters>(process, peb.process_parameters.cast())
|
|
}
|
|
|
|
fn command_line_to_argv(command_line: &str) -> Option<Vec<String>> {
|
|
let wide: Vec<u16> = command_line
|
|
.encode_utf16()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
let mut argc = 0;
|
|
let argv_ptr = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut argc) };
|
|
if argv_ptr.is_null() || argc <= 0 {
|
|
return None;
|
|
}
|
|
|
|
let argv_slice = unsafe { std::slice::from_raw_parts(argv_ptr, argc as usize) };
|
|
let mut argv = Vec::with_capacity(argc as usize);
|
|
for &arg in argv_slice {
|
|
if arg.is_null() {
|
|
continue;
|
|
}
|
|
let mut len = 0;
|
|
unsafe {
|
|
while *arg.add(len) != 0 {
|
|
len += 1;
|
|
}
|
|
argv.push(String::from_utf16_lossy(std::slice::from_raw_parts(
|
|
arg, len,
|
|
)));
|
|
}
|
|
}
|
|
unsafe {
|
|
LocalFree(argv_ptr.cast());
|
|
}
|
|
Some(argv)
|
|
}
|
|
|
|
fn nul_terminated_utf16_to_string(buffer: &[u16]) -> String {
|
|
let len = buffer
|
|
.iter()
|
|
.position(|&value| value == 0)
|
|
.unwrap_or(buffer.len());
|
|
String::from_utf16_lossy(&buffer[..len])
|
|
}
|
|
|
|
pub fn session_processes(child_pid: u32) -> Vec<u32> {
|
|
if child_pid == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let snapshot = ProcessSnapshot::new(snapshot_processes());
|
|
session_processes_from_snapshot(child_pid, &snapshot)
|
|
}
|
|
|
|
fn session_processes_from_snapshot(child_pid: u32, snapshot: &ProcessSnapshot) -> Vec<u32> {
|
|
if snapshot.entry(child_pid).is_none() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut pids = vec![child_pid];
|
|
pids.extend(
|
|
descendant_entries(child_pid, snapshot)
|
|
.into_iter()
|
|
.map(|entry| entry.pid),
|
|
);
|
|
pids
|
|
}
|
|
|
|
pub fn signal_processes(pids: &[u32], signal: Signal) {
|
|
if signal == Signal::Hangup {
|
|
return;
|
|
}
|
|
|
|
for &pid in pids {
|
|
let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
|
|
continue;
|
|
};
|
|
unsafe {
|
|
TerminateProcess(process.0, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn process_exists(pid: u32) -> bool {
|
|
let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
|
|
return false;
|
|
};
|
|
|
|
let mut exit_code = 0;
|
|
let ok = unsafe { GetExitCodeProcess(process.0, &mut exit_code) } != 0;
|
|
ok && exit_code == STILL_ACTIVE
|
|
}
|
|
|
|
static LAST_CLIPBOARD_WRITE_SEQUENCE: AtomicU32 = AtomicU32::new(0);
|
|
|
|
pub fn write_clipboard(bytes: &[u8]) -> bool {
|
|
let Ok(text) = std::str::from_utf8(bytes) else {
|
|
return false;
|
|
};
|
|
if text.contains('\0') {
|
|
return false;
|
|
}
|
|
let mut utf16: Vec<u16> = text.encode_utf16().collect();
|
|
utf16.push(0);
|
|
let Some(byte_len) = utf16.len().checked_mul(size_of::<u16>()) else {
|
|
return false;
|
|
};
|
|
|
|
unsafe {
|
|
let owner = GetConsoleWindow();
|
|
if owner.is_null() || OpenClipboard(owner) == 0 {
|
|
return false;
|
|
}
|
|
let _clipboard = ClipboardGuard;
|
|
|
|
if EmptyClipboard() == 0 {
|
|
return false;
|
|
}
|
|
|
|
let memory = GlobalAlloc(GMEM_MOVEABLE, byte_len);
|
|
if memory.is_null() {
|
|
return false;
|
|
}
|
|
|
|
let locked = GlobalLock(memory);
|
|
if locked.is_null() {
|
|
GlobalFree(memory);
|
|
return false;
|
|
}
|
|
copy_nonoverlapping(utf16.as_ptr(), locked.cast::<u16>(), utf16.len());
|
|
GlobalUnlock(memory);
|
|
|
|
if SetClipboardData(CF_UNICODETEXT as u32, memory).is_null() {
|
|
GlobalFree(memory);
|
|
return false;
|
|
}
|
|
|
|
// Closing may generate additional text formats and advance the sequence.
|
|
drop(_clipboard);
|
|
// Read the sequence first: a later writer must not become our last write.
|
|
let sequence = GetClipboardSequenceNumber();
|
|
let sequence = if GetClipboardOwner() == owner {
|
|
sequence
|
|
} else {
|
|
0
|
|
};
|
|
LAST_CLIPBOARD_WRITE_SEQUENCE.store(sequence, AtomicOrdering::Relaxed);
|
|
true
|
|
}
|
|
}
|
|
|
|
pub fn read_clipboard_text() -> Option<String> {
|
|
None
|
|
}
|
|
|
|
/// Whether the system clipboard currently holds exactly this text.
|
|
///
|
|
/// Returns `None` when the clipboard changed since our last write, cannot be read,
|
|
/// or has non-text formats.
|
|
/// Kept separate from [`read_clipboard_text`] so unsupported modal paste on
|
|
/// Windows is unchanged.
|
|
pub fn clipboard_text_matches(bytes: &[u8]) -> Option<bool> {
|
|
let current = read_clipboard_unicode_text()?;
|
|
Some(clipboard_text_equals(¤t, bytes))
|
|
}
|
|
|
|
fn clipboard_text_equals(current: &str, bytes: &[u8]) -> bool {
|
|
let Ok(payload) = std::str::from_utf8(bytes) else {
|
|
return false;
|
|
};
|
|
normalized_clipboard_newlines(payload) == normalized_clipboard_newlines(current)
|
|
}
|
|
|
|
fn normalized_clipboard_newlines(text: &str) -> std::borrow::Cow<'_, str> {
|
|
if text.contains("\r\n") {
|
|
std::borrow::Cow::Owned(text.replace("\r\n", "\n"))
|
|
} else {
|
|
std::borrow::Cow::Borrowed(text)
|
|
}
|
|
}
|
|
|
|
fn plain_text_clipboard_format(format: u32) -> bool {
|
|
format == CF_UNICODETEXT as u32
|
|
|| format == CF_TEXT as u32
|
|
|| format == CF_OEMTEXT as u32
|
|
|| format == CF_LOCALE as u32
|
|
}
|
|
|
|
fn read_clipboard_unicode_text() -> Option<String> {
|
|
const MAX_CLIPBOARD_TEXT_BYTES: usize = 1024 * 1024;
|
|
|
|
for attempt in 0..10 {
|
|
if unsafe { OpenClipboard(null_mut()) } != 0 {
|
|
let _clipboard = ClipboardGuard;
|
|
let sequence = unsafe { GetClipboardSequenceNumber() };
|
|
if sequence == 0
|
|
|| sequence != LAST_CLIPBOARD_WRITE_SEQUENCE.load(AtomicOrdering::Relaxed)
|
|
{
|
|
return None;
|
|
}
|
|
let format_count = unsafe { CountClipboardFormats() };
|
|
if format_count <= 0 {
|
|
return None;
|
|
}
|
|
let mut format = 0;
|
|
for _ in 0..format_count {
|
|
format = unsafe { EnumClipboardFormats(format) };
|
|
if format == 0 || !plain_text_clipboard_format(format) {
|
|
return None;
|
|
}
|
|
}
|
|
let bytes = clipboard_global_bytes(CF_UNICODETEXT as u32, MAX_CLIPBOARD_TEXT_BYTES)?;
|
|
let units = bytes
|
|
.chunks_exact(2)
|
|
.map(|pair| u16::from_ne_bytes([pair[0], pair[1]]))
|
|
.take_while(|unit| *unit != 0);
|
|
return String::from_utf16(&units.collect::<Vec<_>>()).ok();
|
|
}
|
|
if attempt < 9 {
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn open_url(url: &str) -> std::io::Result<Option<std::process::Child>> {
|
|
let operation = wide_null("open");
|
|
let url = wide_null(url);
|
|
let result = unsafe {
|
|
ShellExecuteW(
|
|
std::ptr::null_mut(),
|
|
operation.as_ptr(),
|
|
url.as_ptr(),
|
|
std::ptr::null(),
|
|
std::ptr::null(),
|
|
1,
|
|
)
|
|
};
|
|
if result as isize > 32 {
|
|
Ok(None)
|
|
} else {
|
|
Err(std::io::Error::other(format!(
|
|
"failed to open URL with ShellExecuteW: code {}",
|
|
result as isize
|
|
)))
|
|
}
|
|
}
|
|
|
|
pub fn read_clipboard_image() -> Option<ClipboardImage> {
|
|
for attempt in 0..10 {
|
|
if unsafe { OpenClipboard(null_mut()) } != 0 {
|
|
let _clipboard = ClipboardGuard;
|
|
if let Some(bytes) = read_registered_png_clipboard() {
|
|
return Some(ClipboardImage {
|
|
bytes,
|
|
extension: "png",
|
|
});
|
|
}
|
|
for format in [CF_DIBV5 as u32, CF_DIB as u32] {
|
|
if let Some(bytes) =
|
|
clipboard_global_bytes(format, clipboard_image::MAX_CLIPBOARD_ALLOCATION)
|
|
{
|
|
if let Some(bytes) = clipboard_image::dib_to_png(&bytes) {
|
|
return Some(ClipboardImage {
|
|
bytes,
|
|
extension: "png",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
if attempt < 9 {
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn read_registered_png_clipboard() -> Option<Vec<u8>> {
|
|
static PNG_FORMAT: LazyLock<u32> = LazyLock::new(|| {
|
|
let name = wide_null("PNG");
|
|
unsafe { RegisterClipboardFormatW(name.as_ptr()) }
|
|
});
|
|
if *PNG_FORMAT == 0 {
|
|
return None;
|
|
}
|
|
let bytes = clipboard_global_bytes(
|
|
*PNG_FORMAT,
|
|
crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD + 64 * 1024,
|
|
)?;
|
|
clipboard_image::validated_png(&bytes)
|
|
}
|
|
|
|
fn clipboard_global_bytes(format: u32, max_bytes: usize) -> Option<Vec<u8>> {
|
|
let handle = unsafe { GetClipboardData(format) };
|
|
if handle.is_null() {
|
|
return None;
|
|
}
|
|
let data = unsafe { GlobalLock(handle) };
|
|
if data.is_null() {
|
|
return None;
|
|
}
|
|
let size = unsafe { GlobalSize(handle) };
|
|
if size == 0 || size > max_bytes {
|
|
unsafe {
|
|
GlobalUnlock(handle);
|
|
}
|
|
return None;
|
|
}
|
|
let mut bytes = vec![0_u8; size];
|
|
unsafe {
|
|
copy_nonoverlapping(data.cast::<u8>(), bytes.as_mut_ptr(), size);
|
|
GlobalUnlock(handle);
|
|
}
|
|
Some(bytes)
|
|
}
|
|
|
|
fn wide_null(value: &str) -> Vec<u16> {
|
|
value.encode_utf16().chain(std::iter::once(0)).collect()
|
|
}
|
|
|
|
struct ProcessHandle(HANDLE);
|
|
|
|
struct ClipboardGuard;
|
|
|
|
impl Drop for ClipboardGuard {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
CloseClipboard();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ProcessHandle {
|
|
fn open(pid: u32, access: u32) -> Option<Self> {
|
|
if pid == 0 {
|
|
return None;
|
|
}
|
|
#[cfg(test)]
|
|
PROCESS_INSPECTION_COUNTS.with_borrow_mut(|counts| counts.opens += 1);
|
|
let handle = unsafe { OpenProcess(access, 0, pid) };
|
|
(!handle.is_null()).then_some(Self(handle))
|
|
}
|
|
}
|
|
|
|
impl Drop for ProcessHandle {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
CloseHandle(self.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct Peb {
|
|
reserved1: [u8; 2],
|
|
being_debugged: u8,
|
|
reserved2: [u8; 1],
|
|
reserved3: [*mut c_void; 2],
|
|
ldr: *mut c_void,
|
|
process_parameters: *mut RtlUserProcessParameters,
|
|
}
|
|
|
|
// Prefixes of the x86 PEB and RTL_USER_PROCESS_PARAMETERS through the fields
|
|
// needed for cwd; remote pointers must stay 32-bit on a 64-bit reader.
|
|
#[cfg(target_pointer_width = "64")]
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct Peb32 {
|
|
reserved: [u32; 4],
|
|
process_parameters: u32,
|
|
}
|
|
|
|
#[cfg(target_pointer_width = "64")]
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct ProcessCwdParameters32 {
|
|
reserved: [u32; 9],
|
|
current_directory: STRING32,
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct CurDir {
|
|
dos_path: UNICODE_STRING,
|
|
handle: HANDLE,
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct RtlUserProcessParameters {
|
|
maximum_length: u32,
|
|
length: u32,
|
|
flags: u32,
|
|
debug_flags: u32,
|
|
console_handle: HANDLE,
|
|
console_flags: u32,
|
|
standard_input: HANDLE,
|
|
standard_output: HANDLE,
|
|
standard_error: HANDLE,
|
|
current_directory: CurDir,
|
|
dll_path: UNICODE_STRING,
|
|
image_path_name: UNICODE_STRING,
|
|
command_line: UNICODE_STRING,
|
|
environment: *mut c_void,
|
|
}
|
|
|
|
fn read_process_value<T: Copy>(process: HANDLE, address: *const c_void) -> Option<T> {
|
|
if address.is_null() {
|
|
return None;
|
|
}
|
|
|
|
let mut value = MaybeUninit::<T>::uninit();
|
|
let mut bytes_read = 0;
|
|
let ok = unsafe {
|
|
ReadProcessMemory(
|
|
process,
|
|
address,
|
|
value.as_mut_ptr().cast::<c_void>(),
|
|
size_of::<T>(),
|
|
&mut bytes_read,
|
|
)
|
|
} != 0;
|
|
|
|
(ok && bytes_read == size_of::<T>()).then(|| unsafe { value.assume_init() })
|
|
}
|
|
|
|
fn read_unicode_string(process: HANDLE, unicode: UNICODE_STRING) -> Option<String> {
|
|
if unicode.Buffer.is_null() || unicode.Length == 0 || !unicode.Length.is_multiple_of(2) {
|
|
return None;
|
|
}
|
|
|
|
let char_len = usize::from(unicode.Length / 2);
|
|
let mut buffer = vec![0_u16; char_len];
|
|
let mut bytes_read = 0;
|
|
let ok = unsafe {
|
|
ReadProcessMemory(
|
|
process,
|
|
unicode.Buffer.cast::<c_void>(),
|
|
buffer.as_mut_ptr().cast::<c_void>(),
|
|
usize::from(unicode.Length),
|
|
&mut bytes_read,
|
|
)
|
|
} != 0;
|
|
|
|
if !ok || bytes_read != usize::from(unicode.Length) {
|
|
return None;
|
|
}
|
|
|
|
String::from_utf16(&buffer).ok()
|
|
}
|
|
|
|
// Prefix-mode ASCII input source support (see `switch_ascii_input_source_in_prefix`).
|
|
//
|
|
// Windows IMEs live in the terminal-emulator process, not in herdr. Empirically:
|
|
// - `WM_IME_CONTROL` / `IMC_GETOPENSTATUS` reads whether the IME is open
|
|
// (composing native characters) reliably across the process boundary (this
|
|
// is what kren-select uses), so we detect state with it. The read goes
|
|
// through `SendMessageTimeoutW` (`SMTO_ABORTIFHUNG`) so a hung host process
|
|
// cannot block us indefinitely.
|
|
// - Writing the state back (`IMC_SETOPENSTATUS` / `IMC_SETCONVERSIONMODE`)
|
|
// changes the flag value but does NOT affect real input in terminal/TSF
|
|
// hosts, so we cannot switch by writing the mode.
|
|
// - `ImmGetContext` on the foreground window returns null across the process
|
|
// boundary, so the ImmGetOpenStatus/ImmSetOpenStatus path is unavailable.
|
|
// Therefore we switch the way kren-select does: inject the IME toggle key with
|
|
// `SendInput`, which reaches the foreground input queue like a real keypress.
|
|
//
|
|
// The toggle key is language-specific, so we pick it from the foreground
|
|
// keyboard layout's language id. Only Korean is mapped today; other IMEs are
|
|
// detected and left untouched (a no-op) rather than toggled with the wrong key.
|
|
|
|
/// `WM_IME_CONTROL` sub-command that reads whether the IME is open, i.e.
|
|
/// composing native characters. This is `IMC_GETOPENSTATUS` (0x0005); for the
|
|
/// Korean IME "open" is exactly the Hangul state and "closed" is English/ASCII
|
|
/// direct input, which is the state we detect and toggle.
|
|
const IMC_GETOPENSTATUS: usize = 0x0005;
|
|
|
|
/// Virtual key that toggles Hangul/English on Korean IMEs.
|
|
const VK_HANGUL: u16 = 0x15;
|
|
|
|
/// Primary language id (low 10 bits of a LANGID) for Korean.
|
|
const LANG_KOREAN: u32 = 0x12;
|
|
|
|
/// Whether the IME reports itself open, i.e. composing native characters
|
|
/// (Hangul for the Korean IME). `IMC_GETOPENSTATUS` returns nonzero when the
|
|
/// IME is open and zero when it is in direct English/ASCII input.
|
|
fn ime_open(open_status: isize) -> bool {
|
|
open_status != 0
|
|
}
|
|
|
|
/// Timeout (ms) for the cross-process IME open-status read. Short enough that a
|
|
/// hung terminal never freezes prefix-mode entry/exit.
|
|
const IME_STATUS_READ_TIMEOUT_MS: u32 = 200;
|
|
|
|
/// Reads the IME open status (`IMC_GETOPENSTATUS`) with a bounded timeout.
|
|
///
|
|
/// `WM_IME_CONTROL` crosses into the terminal-emulator process, and a plain
|
|
/// `SendMessageW` would block herdr's client thread until that process responds
|
|
/// (indefinitely if it is hung). `SendMessageTimeoutW` with `SMTO_ABORTIFHUNG`
|
|
/// caps the wait; on timeout or failure this returns `None` and callers leave
|
|
/// the IME untouched rather than blocking or guessing.
|
|
fn read_ime_open_status(ime_hwnd: HWND) -> Option<isize> {
|
|
let mut result: usize = 0;
|
|
// SAFETY: `ime_hwnd` is a non-null IME window from `ImmGetDefaultIMEWnd`, and
|
|
// `result` is a valid out-pointer for the message's `DWORD_PTR` result.
|
|
let ret = unsafe {
|
|
SendMessageTimeoutW(
|
|
ime_hwnd,
|
|
WM_IME_CONTROL,
|
|
IMC_GETOPENSTATUS,
|
|
0,
|
|
SMTO_ABORTIFHUNG,
|
|
IME_STATUS_READ_TIMEOUT_MS,
|
|
&mut result,
|
|
)
|
|
};
|
|
if ret == 0 {
|
|
// Timed out or failed; do not block or assume a state.
|
|
return None;
|
|
}
|
|
Some(result as isize)
|
|
}
|
|
|
|
/// The IME toggle key for a keyboard layout language id, or `None` when the
|
|
/// language's toggle key is not known. `langid` is the full LANGID (LOWORD of
|
|
/// an `HKL`); the primary language is its low 10 bits.
|
|
///
|
|
/// Only Korean is mapped: `VK_HANGUL` is the Hangul/English toggle. Japanese
|
|
/// (half/full-width) and Chinese use different keys per IME, so they return
|
|
/// `None` and are left untouched instead of toggled incorrectly.
|
|
fn toggle_key_for_language(langid: u32) -> Option<u16> {
|
|
match langid & 0x3FF {
|
|
LANG_KOREAN => Some(VK_HANGUL),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Builds the key-down then key-up `INPUT` pair for `vk`.
|
|
fn key_tap_inputs(vk: u16) -> [INPUT; 2] {
|
|
let key_event = |flags| INPUT {
|
|
r#type: INPUT_KEYBOARD,
|
|
Anonymous: INPUT_0 {
|
|
ki: KEYBDINPUT {
|
|
wVk: vk,
|
|
wScan: 0,
|
|
dwFlags: flags,
|
|
time: 0,
|
|
dwExtraInfo: 0,
|
|
},
|
|
},
|
|
};
|
|
[key_event(0), key_event(KEYEVENTF_KEYUP)]
|
|
}
|
|
|
|
/// Injects a key-down then key-up for `vk` via `SendInput`.
|
|
///
|
|
/// Returns `true` when the key-down was queued and the IME may have toggled.
|
|
/// Thin wrapper over [`send_vk_tap_with`] that plugs in the real `SendInput`;
|
|
/// the injection policy lives there so it can be unit-tested without the OS.
|
|
fn send_vk_tap(vk: u16) -> bool {
|
|
send_vk_tap_with(vk, |events| {
|
|
// SAFETY: `events` outlives the call; its `INPUT_KEYBOARD` entries have
|
|
// the `ki` union variant fully initialized, which is the variant
|
|
// SendInput reads for keyboard input. `size_of::<INPUT>()` is the
|
|
// required `cbSize`.
|
|
unsafe {
|
|
SendInput(
|
|
events.len() as u32,
|
|
events.as_ptr(),
|
|
size_of::<INPUT>() as i32,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Core key-tap logic with the raw event injector abstracted behind `inject`,
|
|
/// which returns how many of the passed events it actually queued. This keeps
|
|
/// the success / partial-injection / total-failure branches unit-testable
|
|
/// without touching the real `SendInput`.
|
|
///
|
|
/// `SendInput` returns how many events it queued; a short count means injection
|
|
/// was blocked (e.g. by UIPI). Returns `true` whenever the key-down was queued,
|
|
/// because the IME may have toggled and callers must retain restoration state.
|
|
/// When only the key-down landed, the key-up is retried so the key is not left
|
|
/// logically held down.
|
|
fn send_vk_tap_with(vk: u16, mut inject: impl FnMut(&[INPUT]) -> u32) -> bool {
|
|
let inputs = key_tap_inputs(vk);
|
|
let sent = inject(&inputs);
|
|
if sent as usize == inputs.len() {
|
|
return true;
|
|
}
|
|
|
|
if sent == 1 {
|
|
// The key-down landed and may already have toggled the IME. Retry the
|
|
// dropped key-up, but report that restoration state is still required.
|
|
let key_up = [inputs[1]];
|
|
let up_sent = inject(&key_up);
|
|
tracing::warn!(
|
|
vk,
|
|
sent,
|
|
expected = inputs.len(),
|
|
key_up_retry_sent = up_sent,
|
|
"SendInput dropped the IME toggle key-up; retried key-up"
|
|
);
|
|
return true;
|
|
}
|
|
|
|
tracing::warn!(
|
|
vk,
|
|
sent,
|
|
expected = inputs.len(),
|
|
"SendInput did not inject the IME toggle key tap"
|
|
);
|
|
false
|
|
}
|
|
|
|
pub(crate) fn pump_input_source_runloop() {}
|
|
|
|
/// Switch the foreground window's IME to ASCII-capable input for prefix mode.
|
|
///
|
|
/// Returns `None` (nothing to restore) when there is no foreground IME, the
|
|
/// keyboard language has no known toggle key, or the IME is already
|
|
/// ASCII-capable, matching the macOS contract.
|
|
pub(crate) fn switch_to_ascii_input_source() -> Option<InputSourceRestore> {
|
|
// SAFETY: all calls are Win32 UI functions invoked on the client's main
|
|
// thread. Every HWND is null-checked before use; `fg_thread` is a thread id
|
|
// (not a handle) used only as `GetKeyboardLayout` input, where 0 harmlessly
|
|
// falls back to the calling thread's layout.
|
|
unsafe {
|
|
let fg = GetForegroundWindow();
|
|
if fg.is_null() {
|
|
return None;
|
|
}
|
|
|
|
// Pick the toggle key for the foreground keyboard language. Unknown
|
|
// languages (Japanese, Chinese, ...) are left untouched.
|
|
let fg_thread = GetWindowThreadProcessId(fg, null_mut());
|
|
let langid = (GetKeyboardLayout(fg_thread) as usize as u32) & 0xFFFF;
|
|
let Some(toggle_vk) = toggle_key_for_language(langid) else {
|
|
tracing::debug!(
|
|
langid = format!("{langid:#06x}"),
|
|
"prefix IME switch: no toggle key for keyboard language, leaving IME as-is"
|
|
);
|
|
return None;
|
|
};
|
|
|
|
// Detect the open (Hangul) state via the bounded read path.
|
|
let ime_hwnd = ImmGetDefaultIMEWnd(fg);
|
|
if ime_hwnd.is_null() {
|
|
return None;
|
|
}
|
|
let Some(open) = read_ime_open_status(ime_hwnd) else {
|
|
tracing::debug!("prefix IME switch skipped: IME open-status read timed out");
|
|
return None;
|
|
};
|
|
if !ime_open(open) {
|
|
// Already in English/ASCII input; nothing to switch or restore.
|
|
return None;
|
|
}
|
|
|
|
// The bounded cross-process status read can take long enough for focus
|
|
// to change. Recheck immediately before using the global input queue.
|
|
if GetForegroundWindow() != fg {
|
|
tracing::debug!("prefix IME switch skipped: foreground window changed");
|
|
return None;
|
|
}
|
|
|
|
// Toggle to ASCII by injecting the language's IME toggle key. Only arm
|
|
// restoration when the toggle actually landed, so we never try to
|
|
// restore a switch that never happened.
|
|
if !send_vk_tap(toggle_vk) {
|
|
tracing::warn!(
|
|
langid = format!("{langid:#06x}"),
|
|
"prefix IME switch: toggle injection failed, leaving IME as-is"
|
|
);
|
|
return None;
|
|
}
|
|
tracing::debug!(
|
|
langid = format!("{langid:#06x}"),
|
|
"switched host IME to ASCII for prefix mode"
|
|
);
|
|
Some(InputSourceRestore {
|
|
toggle_vk,
|
|
origin_hwnd: fg as isize,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Restores the native (Hangul) IME state that was active before prefix mode.
|
|
///
|
|
/// Only constructed by [`switch_to_ascii_input_source`] after it successfully
|
|
/// toggled the IME to English/ASCII. Dropping it re-injects the same toggle key
|
|
/// to go back, but only after two guards, so restoration never fights the user
|
|
/// or another application:
|
|
/// - the same window that was switched must still be focused, otherwise the
|
|
/// toggle would land on whatever app the user moved to;
|
|
/// - the IME must still be in English (our switch still in effect), otherwise
|
|
/// the user manually returned to Hangul during prefix mode and we must leave
|
|
/// their choice alone.
|
|
///
|
|
/// `origin_hwnd` stores the foreground window at switch time as raw pointer bits
|
|
/// (`isize`, not `HWND`) so the guard stays `Send` when parked in the client's
|
|
/// prefix-input state across `.await` points.
|
|
#[derive(Debug)]
|
|
pub(crate) struct InputSourceRestore {
|
|
toggle_vk: u16,
|
|
origin_hwnd: isize,
|
|
}
|
|
|
|
impl Drop for InputSourceRestore {
|
|
fn drop(&mut self) {
|
|
// SAFETY: all calls are Win32 UI functions invoked on the client's main
|
|
// thread. Every HWND is null-checked before use.
|
|
unsafe {
|
|
// Guard 1: only restore if the window we switched is still focused,
|
|
// so the toggle never lands on a different application.
|
|
let fg = GetForegroundWindow();
|
|
if fg.is_null() || fg as isize != self.origin_hwnd {
|
|
tracing::debug!(
|
|
"prefix IME restore skipped: foreground window changed since switch"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Guard 2: only restore if the IME is still in English (our switch is
|
|
// still in effect). If the user manually switched back to Hangul
|
|
// during prefix mode, leave their choice untouched.
|
|
let ime_hwnd = ImmGetDefaultIMEWnd(fg);
|
|
if ime_hwnd.is_null() {
|
|
return;
|
|
}
|
|
let Some(open) = read_ime_open_status(ime_hwnd) else {
|
|
tracing::debug!("prefix IME restore skipped: IME open-status read timed out");
|
|
return;
|
|
};
|
|
if ime_open(open) {
|
|
tracing::debug!("prefix IME restore skipped: IME already back to native input");
|
|
return;
|
|
}
|
|
|
|
// The bounded cross-process status read can take long enough for
|
|
// focus to change. Recheck immediately before using SendInput.
|
|
if GetForegroundWindow() != fg {
|
|
tracing::debug!("prefix IME restore skipped: foreground window changed");
|
|
return;
|
|
}
|
|
|
|
if send_vk_tap(self.toggle_vk) {
|
|
tracing::debug!("restored host IME after prefix mode");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
fs,
|
|
process::{Command, Stdio},
|
|
sync::Arc,
|
|
thread,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use windows_sys::Win32::System::Console::{
|
|
AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow,
|
|
};
|
|
|
|
#[test]
|
|
fn busy_endpoint_connect_obeys_cancellation_and_deadline() {
|
|
use std::os::windows::io::{FromRawHandle, OwnedHandle};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
|
|
use windows_sys::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
|
|
use windows_sys::Win32::System::Pipes::CreateNamedPipeW;
|
|
let path = std::path::PathBuf::from(format!("herdr-busy-{}", std::process::id()));
|
|
let name = super::wide_null(&format!(r"\\.\pipe\{}", path.display()));
|
|
let handle = unsafe {
|
|
CreateNamedPipeW(
|
|
name.as_ptr(),
|
|
PIPE_ACCESS_DUPLEX,
|
|
0,
|
|
1,
|
|
512,
|
|
512,
|
|
0,
|
|
std::ptr::null(),
|
|
)
|
|
};
|
|
assert_ne!(handle, INVALID_HANDLE_VALUE);
|
|
let _listener = unsafe { OwnedHandle::from_raw_handle(handle) };
|
|
let _occupant =
|
|
super::connect_local_stream_with_timeout(&path, Duration::from_millis(500)).unwrap();
|
|
for cancel in [false, true] {
|
|
let stopped = Arc::new(AtomicBool::new(false));
|
|
let worker_stop = Arc::clone(&stopped);
|
|
let cancel_worker = thread::spawn(move || {
|
|
if cancel {
|
|
thread::sleep(Duration::from_millis(50));
|
|
worker_stop.store(true, Ordering::Release);
|
|
}
|
|
});
|
|
let start = Instant::now();
|
|
let result = crate::ipc::connect_local_stream_cancellable(
|
|
&path,
|
|
&stopped,
|
|
start
|
|
+ if cancel {
|
|
Duration::from_secs(5)
|
|
} else {
|
|
Duration::from_millis(200)
|
|
},
|
|
);
|
|
cancel_worker.join().unwrap();
|
|
assert_eq!(
|
|
result.unwrap_err().kind(),
|
|
if cancel {
|
|
std::io::ErrorKind::ConnectionAborted
|
|
} else {
|
|
std::io::ErrorKind::TimedOut
|
|
}
|
|
);
|
|
assert!(start.elapsed() < Duration::from_secs(1));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn local_resources_authorize_account_without_admin_rights() {
|
|
use interprocess::local_socket::traits::Listener as _;
|
|
use std::io::{Read, Write};
|
|
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
|
use windows_sys::Win32::Security::{
|
|
CreateRestrictedToken, GetTokenInformation, ImpersonateLoggedOnUser, RevertToSelf,
|
|
TokenUser, DISABLE_MAX_PRIVILEGE, LUA_TOKEN, SID_AND_ATTRIBUTES, TOKEN_DUPLICATE,
|
|
TOKEN_QUERY, TOKEN_USER,
|
|
};
|
|
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
|
|
|
let path =
|
|
std::env::temp_dir().join(format!("herdr-user-pipe-{}.sock", std::process::id()));
|
|
let listener = crate::ipc::bind_local_listener(&path).unwrap();
|
|
let mut raw_token = std::ptr::null_mut();
|
|
assert_ne!(
|
|
unsafe {
|
|
OpenProcessToken(
|
|
GetCurrentProcess(),
|
|
TOKEN_DUPLICATE | TOKEN_QUERY,
|
|
&mut raw_token,
|
|
)
|
|
},
|
|
0
|
|
);
|
|
let token = unsafe { OwnedHandle::from_raw_handle(raw_token) };
|
|
let mut restricted = std::ptr::null_mut();
|
|
assert_ne!(
|
|
unsafe {
|
|
CreateRestrictedToken(
|
|
token.as_raw_handle(),
|
|
DISABLE_MAX_PRIVILEGE | LUA_TOKEN,
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
&mut restricted,
|
|
)
|
|
},
|
|
0
|
|
);
|
|
let restricted = unsafe { OwnedHandle::from_raw_handle(restricted) };
|
|
let private_path = path.with_extension("private");
|
|
super::create_config_temporary(&private_path, true)
|
|
.unwrap()
|
|
.write_all(b"recovery")
|
|
.unwrap();
|
|
assert_ne!(
|
|
unsafe { ImpersonateLoggedOnUser(restricted.as_raw_handle()) },
|
|
0
|
|
);
|
|
let connection = crate::ipc::connect_local_stream(&path);
|
|
let private_read = fs::read(&private_path);
|
|
let private_write = fs::write(&private_path, b"updated");
|
|
let reverted = unsafe { RevertToSelf() };
|
|
assert_ne!(reverted, 0);
|
|
assert_eq!(private_read.unwrap(), b"recovery");
|
|
private_write.unwrap();
|
|
let mut client = connection.expect("the account SID must work without admin membership");
|
|
let mut server = listener.accept().unwrap();
|
|
client.write_all(b"account").unwrap();
|
|
let mut received = [0; 7];
|
|
server.read_exact(&mut received).unwrap();
|
|
assert_eq!(&received, b"account");
|
|
|
|
// Removing the account SID must not leave access through Everyone or
|
|
// another ordinary group. This exercises the real DACL access check.
|
|
let mut size = 0;
|
|
unsafe {
|
|
GetTokenInformation(
|
|
token.as_raw_handle(),
|
|
TokenUser,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
&mut size,
|
|
)
|
|
};
|
|
let mut user = vec![0usize; (size as usize).div_ceil(std::mem::size_of::<usize>())];
|
|
assert_ne!(
|
|
unsafe {
|
|
GetTokenInformation(
|
|
token.as_raw_handle(),
|
|
TokenUser,
|
|
user.as_mut_ptr().cast(),
|
|
size,
|
|
&mut size,
|
|
)
|
|
},
|
|
0
|
|
);
|
|
let user = unsafe { &*user.as_ptr().cast::<TOKEN_USER>() };
|
|
let disabled = SID_AND_ATTRIBUTES {
|
|
Sid: user.User.Sid,
|
|
Attributes: 0,
|
|
};
|
|
let mut without_account = std::ptr::null_mut();
|
|
assert_ne!(
|
|
unsafe {
|
|
CreateRestrictedToken(
|
|
token.as_raw_handle(),
|
|
DISABLE_MAX_PRIVILEGE | LUA_TOKEN,
|
|
1,
|
|
&disabled,
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
&mut without_account,
|
|
)
|
|
},
|
|
0
|
|
);
|
|
let without_account = unsafe { OwnedHandle::from_raw_handle(without_account) };
|
|
assert_ne!(
|
|
unsafe { ImpersonateLoggedOnUser(without_account.as_raw_handle()) },
|
|
0
|
|
);
|
|
let denied = crate::ipc::connect_local_stream(&path);
|
|
let private_denied = fs::read(&private_path);
|
|
let reverted = unsafe { RevertToSelf() };
|
|
assert_ne!(reverted, 0);
|
|
assert_eq!(
|
|
denied.unwrap_err().kind(),
|
|
std::io::ErrorKind::PermissionDenied
|
|
);
|
|
assert_eq!(
|
|
private_denied.unwrap_err().kind(),
|
|
std::io::ErrorKind::PermissionDenied
|
|
);
|
|
assert_ne!(
|
|
unsafe { ImpersonateLoggedOnUser(restricted.as_raw_handle()) },
|
|
0
|
|
);
|
|
let removed = fs::remove_file(private_path);
|
|
let reverted = unsafe { RevertToSelf() };
|
|
assert_ne!(reverted, 0);
|
|
removed.expect("the account must be able to remove its private recovery files");
|
|
drop(client);
|
|
drop(server);
|
|
drop(listener);
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_text_equals_normalizes_line_endings() {
|
|
assert!(super::clipboard_text_equals("hello", b"hello"));
|
|
assert!(super::clipboard_text_equals("a\r\nb", b"a\nb"));
|
|
assert!(super::clipboard_text_equals("a\nb", b"a\r\nb"));
|
|
assert!(!super::clipboard_text_equals("hello ", b"hello"));
|
|
assert!(!super::clipboard_text_equals("hello", b"world"));
|
|
assert!(!super::clipboard_text_equals("hello", &[0xff]));
|
|
assert!(!super::clipboard_text_equals("a\rb", b"a\nb"));
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_format_check_rejects_rich_content() {
|
|
for format in [
|
|
super::CF_UNICODETEXT,
|
|
super::CF_TEXT,
|
|
super::CF_OEMTEXT,
|
|
super::CF_LOCALE,
|
|
] {
|
|
assert!(super::plain_text_clipboard_format(format as u32));
|
|
}
|
|
assert!(!super::plain_text_clipboard_format(super::CF_DIB as u32));
|
|
assert!(!super::plain_text_clipboard_format(0xC000));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_standard_plugin_runtime_paths_drop_only_disk_and_unc_verbatim_prefixes() {
|
|
assert_eq!(
|
|
super::standard_windows_path(std::path::Path::new(r"\\?\C:\plugins\example")),
|
|
Some(std::path::PathBuf::from(r"C:\plugins\example"))
|
|
);
|
|
assert_eq!(
|
|
super::standard_windows_path(std::path::Path::new(
|
|
r"\\?\UNC\server\share\plugins\example"
|
|
)),
|
|
Some(std::path::PathBuf::from(r"\\server\share\plugins\example"))
|
|
);
|
|
assert_eq!(
|
|
super::standard_windows_path(std::path::Path::new(
|
|
r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\plugins"
|
|
)),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_plugin_runtime_path_keeps_extended_path_when_normal_form_is_not_equivalent() {
|
|
let path = std::path::PathBuf::from(format!(
|
|
r"\\?\C:\herdr-missing-plugin-runtime-path-{}",
|
|
std::process::id()
|
|
));
|
|
assert_eq!(super::plugin_runtime_path_platform(&path), path);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_plugin_runtime_path_keeps_verbatim_root_beyond_max_path() {
|
|
use std::os::windows::ffi::OsStrExt;
|
|
|
|
let base = std::env::temp_dir().join(format!(
|
|
"herdr-plugin-runtime-path-limit-test-{}",
|
|
std::process::id()
|
|
));
|
|
fs::create_dir_all(&base).expect("create test base");
|
|
let extended_base = base.canonicalize().expect("canonicalize test base");
|
|
let normal_base = super::standard_windows_path(&extended_base)
|
|
.expect("test base has a standard drive path");
|
|
let normal_base_len = normal_base.as_os_str().encode_wide().count();
|
|
let root_at_length = |length| {
|
|
let component_len = length - normal_base_len - 1;
|
|
let path = extended_base.join("é".repeat(component_len));
|
|
fs::create_dir(&path).expect("create length-boundary test root");
|
|
path.canonicalize()
|
|
.expect("canonicalize length-boundary test root")
|
|
};
|
|
|
|
let at_limit = root_at_length(windows_sys::Win32::Foundation::MAX_PATH as usize - 2);
|
|
let at_limit_normal =
|
|
super::standard_windows_path(&at_limit).expect("convert root at MAX_PATH boundary");
|
|
assert_eq!(
|
|
super::plugin_runtime_path_platform(&at_limit),
|
|
at_limit_normal
|
|
);
|
|
|
|
let beyond_limit = root_at_length(windows_sys::Win32::Foundation::MAX_PATH as usize - 1);
|
|
assert_eq!(
|
|
super::plugin_runtime_path_platform(&beyond_limit),
|
|
beyond_limit
|
|
);
|
|
|
|
fs::remove_dir_all(base).expect("remove test directory");
|
|
}
|
|
|
|
#[test]
|
|
fn paste_text_uses_windows_line_endings() {
|
|
assert_eq!(
|
|
super::prepare_paste_text_for_pty_platform("one\ntwo\r\nthree\rfour".to_owned()),
|
|
"one\r\ntwo\r\nthree\rfour"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn private_remote_directory_supports_long_paths() {
|
|
let base = std::env::temp_dir().join(format!(
|
|
"herdr-private-remote-dir-test-{}",
|
|
std::process::id()
|
|
));
|
|
fs::create_dir_all(&base).expect("create test base");
|
|
let private = base.join("x".repeat(240));
|
|
|
|
super::create_remote_private_dir(&private).expect("create private long-path directory");
|
|
fs::write(private.join("probe"), b"ok").expect("write inherited private file");
|
|
|
|
fs::remove_dir_all(base).expect("remove test directory");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_conpty_native_encoder_uses_canonical_phase_and_repeat_count() {
|
|
let key = crate::input::TerminalKey::new(
|
|
crossterm::event::KeyCode::Char('7'),
|
|
crossterm::event::KeyModifiers::CONTROL,
|
|
)
|
|
.with_windows_record(crate::input::WindowsKeyRecord {
|
|
key_down: true,
|
|
repeat_count: 3,
|
|
virtual_key_code: 0x37,
|
|
virtual_scan_code: 0x08,
|
|
unicode: 0,
|
|
control_key_state: 0x0008,
|
|
});
|
|
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(&key),
|
|
Some(b"\x1b[55;8;0;1;8;3_".to_vec())
|
|
);
|
|
let mut release = key.with_kind(crossterm::event::KeyEventKind::Release);
|
|
release.repeat_count = 3;
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(&release),
|
|
Some(b"\x1b[55;8;0;0;8;1_".to_vec())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_conpty_native_encoder_preserves_semantic_escape_fallback() {
|
|
let escape = crate::input::TerminalKey::new(
|
|
crossterm::event::KeyCode::Esc,
|
|
crossterm::event::KeyModifiers::empty(),
|
|
);
|
|
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(&escape),
|
|
Some(b"\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_".to_vec())
|
|
);
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(
|
|
&escape
|
|
.clone()
|
|
.with_kind(crossterm::event::KeyEventKind::Repeat),
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(
|
|
&escape
|
|
.clone()
|
|
.with_kind(crossterm::event::KeyEventKind::Release),
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(&escape.clone().with_vt_bytes(vec![27])),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(&crate::input::TerminalKey::new(
|
|
crossterm::event::KeyCode::Esc,
|
|
crossterm::event::KeyModifiers::ALT,
|
|
),),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_conpty_native_encoder_preserves_semantic_shift_enter_fallback() {
|
|
let shift_enter = crate::input::TerminalKey::new(
|
|
crossterm::event::KeyCode::Enter,
|
|
crossterm::event::KeyModifiers::SHIFT,
|
|
);
|
|
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(&shift_enter),
|
|
Some(b"\x1b[13;28;13;1;16;1_".to_vec())
|
|
);
|
|
assert_eq!(
|
|
super::encode_windows_conpty_fallback(&crate::input::TerminalKey::new(
|
|
crossterm::event::KeyCode::Enter,
|
|
crossterm::event::KeyModifiers::empty(),
|
|
)),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn powershell_agent_command_omits_argument_list_when_no_arguments_are_passed() {
|
|
let argv = vec!["opencode".into()];
|
|
|
|
assert_eq!(
|
|
super::interactive_shell_command(&argv, "powershell.exe").as_deref(),
|
|
Some("& opencode")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cmd_agent_command_encodes_edge_arguments_without_cmd_expansion() {
|
|
use base64::Engine as _;
|
|
|
|
assert_eq!(super::super::quote_powershell_arg("@options"), "'@options'");
|
|
let argv = vec![
|
|
"pi".into(),
|
|
String::new(),
|
|
"two words".into(),
|
|
"100%".into(),
|
|
"wow!".into(),
|
|
"a'b".into(),
|
|
"--model".into(),
|
|
];
|
|
let command = super::interactive_shell_command(&argv, "cmd.exe").unwrap();
|
|
let encoded = command.split_whitespace().last().unwrap();
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded)
|
|
.unwrap();
|
|
let utf16 = bytes
|
|
.chunks_exact(2)
|
|
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
String::from_utf16(&utf16).unwrap(),
|
|
"if((Get-Command pi -ErrorAction SilentlyContinue).CommandType -eq 'ExternalScript'){& pi '' 'two words' '100%' 'wow!' 'a''b' '--model'}else{Start-Process -FilePath pi -ArgumentList '\"\" \"two words\" 100% wow! a''b --model' -NoNewWindow -Wait}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_shells_round_trip_agent_arguments_through_a_real_command() {
|
|
let _lock = crate::integration::integration_env_lock();
|
|
let base = std::env::temp_dir().join(format!(
|
|
"herdr-agent-argv-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis()
|
|
));
|
|
fs::create_dir_all(&base).unwrap();
|
|
let helper = base.join("pi.cmd");
|
|
fs::write(
|
|
&helper,
|
|
"@echo off\r\n>\"%HERDR_ARGV_CAPTURE%\" (\r\necho(%~1\r\necho(%~2\r\necho(%~3\r\necho(%~4\r\necho(%~5\r\necho(%~6\r\necho(%~7\r\n)\r\n",
|
|
)
|
|
.unwrap();
|
|
let argv = vec![
|
|
"pi".into(),
|
|
String::new(),
|
|
"two words".into(),
|
|
"100%".into(),
|
|
"wow!".into(),
|
|
"a'b".into(),
|
|
"@options".into(),
|
|
"--model".into(),
|
|
];
|
|
let inherited_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let path = format!("{};{}", base.display(), inherited_path.to_string_lossy());
|
|
let run_command = |shell: &str, command: &str, capture: &std::path::Path| {
|
|
let mut process = if shell == "cmd.exe" {
|
|
let mut process = Command::new("cmd.exe");
|
|
process.args(["/d", "/c", command]);
|
|
process
|
|
} else {
|
|
let mut process = Command::new("powershell.exe");
|
|
process.args(["-NoLogo", "-NoProfile", "-Command", command]);
|
|
process
|
|
};
|
|
process
|
|
.env("PATH", &path)
|
|
.env("HERDR_ARGV_CAPTURE", capture)
|
|
.env("PSExecutionPolicyPreference", "Bypass")
|
|
.status()
|
|
.unwrap()
|
|
};
|
|
|
|
for shell in ["powershell.exe", "cmd.exe"] {
|
|
let no_args_capture = base.join(format!("{shell}-no-args.txt"));
|
|
let no_args_command = super::interactive_shell_command(&["pi".into()], shell).unwrap();
|
|
let status = run_command(shell, &no_args_command, &no_args_capture);
|
|
assert!(status.success(), "{shell} argument-free command failed");
|
|
assert_eq!(
|
|
fs::read_to_string(no_args_capture)
|
|
.unwrap()
|
|
.replace("\r\n", "\n"),
|
|
"\n\n\n\n\n\n\n"
|
|
);
|
|
|
|
let capture = base.join(format!("{shell}.txt"));
|
|
let command = super::interactive_shell_command(&argv, shell).unwrap();
|
|
let status = run_command(shell, &command, &capture);
|
|
assert!(status.success(), "{shell} command failed");
|
|
assert_eq!(
|
|
fs::read_to_string(capture).unwrap().replace("\r\n", "\n"),
|
|
"\ntwo words\n100%\nwow!\na'b\n@options\n--model\n"
|
|
);
|
|
}
|
|
|
|
fs::remove_file(helper).unwrap();
|
|
fs::write(
|
|
base.join("pi.ps1"),
|
|
"Set-Content -LiteralPath $env:HERDR_ARGV_CAPTURE -Value @(\"$($args[0])\", \"$($args[1])\", \"$($args[2])\", \"$($args[3])\", \"$($args[4])\", \"$($args[5])\", \"$($args[6])\")\r\n",
|
|
)
|
|
.unwrap();
|
|
for shell in ["powershell.exe", "cmd.exe"] {
|
|
let capture = base.join(format!("{shell}-ps1.txt"));
|
|
let command = super::interactive_shell_command(&argv, shell).unwrap();
|
|
let status = run_command(shell, &command, &capture);
|
|
assert!(status.success(), "{shell} PowerShell script command failed");
|
|
assert_eq!(
|
|
fs::read_to_string(capture).unwrap().replace("\r\n", "\n"),
|
|
"\ntwo words\n100%\nwow!\na'b\n@options\n--model\n"
|
|
);
|
|
}
|
|
|
|
let _ = fs::remove_dir_all(base);
|
|
}
|
|
|
|
const CONSOLE_TEST_CHILD_ENV: &str = "HERDR_TEST_CONSOLE_CHILD_MODE";
|
|
const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID";
|
|
const WMI_DAEMON_TEST_CHILD_ENV: &str = "HERDR_TEST_WMI_DAEMON_CHILD";
|
|
|
|
#[test]
|
|
fn windows_daemon_readiness_checks_job_limits_and_console() {
|
|
const CHILD_ENV: &str = "HERDR_TEST_DAEMON_READINESS_CHILD";
|
|
if std::env::var_os(CHILD_ENV).is_some() {
|
|
use super::*;
|
|
|
|
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
|
assert!(!job.is_null(), "create test job");
|
|
let job = unsafe { OwnedHandle::from_raw_handle(job) };
|
|
assert_ne!(
|
|
unsafe { AssignProcessToJobObject(job.as_raw_handle(), GetCurrentProcess()) },
|
|
0,
|
|
"assign child to test job"
|
|
);
|
|
assert!(current_process_is_in_job().unwrap());
|
|
assert!(
|
|
current_process_is_detached_server_daemon(),
|
|
"a console-free process in a non-killing job must be ready"
|
|
);
|
|
|
|
for (flags, ready) in [(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, false), (0, true)] {
|
|
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
|
limits.BasicLimitInformation.LimitFlags = flags;
|
|
assert_ne!(
|
|
unsafe {
|
|
SetInformationJobObject(
|
|
job.as_raw_handle(),
|
|
JobObjectExtendedLimitInformation,
|
|
std::ptr::from_ref(&limits).cast(),
|
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
)
|
|
},
|
|
0,
|
|
"set test job limits"
|
|
);
|
|
assert_eq!(current_process_is_detached_server_daemon(), ready);
|
|
}
|
|
assert_ne!(unsafe { AllocConsole() }, 0, "allocate test console");
|
|
assert!(!current_process_is_detached_server_daemon());
|
|
assert_ne!(unsafe { FreeConsole() }, 0, "release test console");
|
|
return;
|
|
}
|
|
|
|
let mut child = Command::new(std::env::current_exe().unwrap());
|
|
child
|
|
.args([
|
|
"--exact",
|
|
"platform::windows::tests::windows_daemon_readiness_checks_job_limits_and_console",
|
|
"--nocapture",
|
|
])
|
|
.env(CHILD_ENV, "1");
|
|
super::detach_server_daemon_command(&mut child);
|
|
let output = child.output().expect("run daemon readiness child");
|
|
assert!(
|
|
output.status.success(),
|
|
"daemon readiness child failed: {}{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_environment_keys_use_unicode_case_insensitive_ordering() {
|
|
assert_eq!(
|
|
super::windows_environment_key_cmp("hérdr", "HÉRDR"),
|
|
std::cmp::Ordering::Equal
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_wmi_daemon_preserves_environment_and_working_directory() {
|
|
if let Some(capture) = std::env::var_os(WMI_DAEMON_TEST_CHILD_ENV) {
|
|
let cwd = std::env::current_dir().expect("WMI daemon test working directory");
|
|
fs::write(
|
|
capture,
|
|
format!(
|
|
"{}\n{}\n{}",
|
|
cwd.display(),
|
|
unsafe { GetConsoleWindow() }.is_null(),
|
|
super::current_process_is_detached_server_daemon()
|
|
),
|
|
)
|
|
.expect("write WMI daemon test capture");
|
|
return;
|
|
}
|
|
|
|
let base = std::env::temp_dir().join(format!(
|
|
"herdr-wmi-daemon-test-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis()
|
|
));
|
|
fs::create_dir_all(&base).unwrap();
|
|
let capture = base.join("capture.txt");
|
|
let test_exe = std::env::current_exe().expect("resolve test executable");
|
|
let mut child = Command::new(test_exe);
|
|
child
|
|
.arg("windows_wmi_daemon_preserves_environment_and_working_directory")
|
|
.current_dir(&base)
|
|
.env(WMI_DAEMON_TEST_CHILD_ENV, &capture)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
|
|
let pid = super::launch_server_daemon_with_wmi(&child)
|
|
.expect("launch detached process through WMI");
|
|
assert_ne!(pid, 0, "WMI returned an invalid process id");
|
|
|
|
let expected = format!("{}\ntrue\ntrue", base.display());
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
if fs::read_to_string(&capture).is_ok_and(|captured| captured == expected) {
|
|
break;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"WMI daemon child did not write the expected capture"
|
|
);
|
|
thread::sleep(Duration::from_millis(50));
|
|
}
|
|
let _ = fs::remove_dir_all(base);
|
|
}
|
|
|
|
fn console_process_ids() -> Vec<u32> {
|
|
let mut process_ids = vec![0; 8];
|
|
loop {
|
|
let count = unsafe {
|
|
GetConsoleProcessList(process_ids.as_mut_ptr(), process_ids.len() as u32)
|
|
} as usize;
|
|
if count == 0 {
|
|
return Vec::new();
|
|
}
|
|
if count <= process_ids.len() {
|
|
process_ids.truncate(count);
|
|
return process_ids;
|
|
}
|
|
process_ids.resize(count, 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_background_and_server_daemon_commands_do_not_have_consoles() {
|
|
if let Some(mode) = std::env::var_os(CONSOLE_TEST_CHILD_ENV) {
|
|
assert!(
|
|
unsafe { GetConsoleWindow() }.is_null(),
|
|
"{} child opened or inherited a console window",
|
|
mode.to_string_lossy()
|
|
);
|
|
let parent_pid = std::env::var(CONSOLE_TEST_PARENT_PID_ENV)
|
|
.expect("console test parent pid")
|
|
.parse::<u32>()
|
|
.expect("numeric console test parent pid");
|
|
assert!(
|
|
!console_process_ids().contains(&parent_pid),
|
|
"{} child inherited the parent console",
|
|
mode.to_string_lossy()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let allocated_console = if console_process_ids().is_empty() {
|
|
assert_ne!(unsafe { AllocConsole() }, 0, "allocate test console");
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
|
|
let parent_pid = std::process::id().to_string();
|
|
let test_exe = std::env::current_exe().expect("resolve test executable");
|
|
type ConfigureCommand = fn(&mut Command);
|
|
let configurations: [(&str, ConfigureCommand); 2] = [
|
|
("background", super::configure_background_command_platform),
|
|
("server daemon", super::detach_server_daemon_command),
|
|
];
|
|
for (mode, configure) in configurations {
|
|
let mut child = Command::new(&test_exe);
|
|
child
|
|
.arg("windows_background_and_server_daemon_commands_do_not_have_consoles")
|
|
.env(CONSOLE_TEST_CHILD_ENV, mode)
|
|
.env(CONSOLE_TEST_PARENT_PID_ENV, &parent_pid)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
configure(&mut child);
|
|
|
|
let status = child.status().expect("spawn console isolation test child");
|
|
assert!(
|
|
status.success(),
|
|
"{mode} child opened or inherited a console"
|
|
);
|
|
}
|
|
|
|
let command = format!(
|
|
r#""{}" windows_background_and_server_daemon_commands_do_not_have_consoles"#,
|
|
test_exe.display()
|
|
);
|
|
let status = crate::platform::detached_custom_command_process(&command)
|
|
.env(CONSOLE_TEST_CHILD_ENV, "detached custom command descendant")
|
|
.env(CONSOLE_TEST_PARENT_PID_ENV, &parent_pid)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.expect("spawn detached custom command test child");
|
|
assert!(
|
|
status.success(),
|
|
"detached custom command descendant opened or inherited a console"
|
|
);
|
|
|
|
if allocated_console {
|
|
unsafe {
|
|
FreeConsole();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn argv_strings(argv: &[std::ffi::OsString]) -> Vec<String> {
|
|
argv.iter()
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn pane_custom_command_uses_cmd() {
|
|
let builder = super::pane_custom_command_pty_builder_with_comspec(
|
|
"echo hello",
|
|
Some(r"C:\Windows\System32\cmd.exe".into()),
|
|
);
|
|
|
|
assert_eq!(
|
|
argv_strings(builder.get_argv()),
|
|
[r"C:\Windows\System32\cmd.exe", "/d", "/c"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detached_custom_command_uses_cmd() {
|
|
let expected_shell = std::env::var_os("ComSpec")
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into())
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
|
|
let process = super::detached_custom_command_process_platform("echo hello");
|
|
|
|
assert_eq!(process.get_program().to_string_lossy(), expected_shell);
|
|
assert_eq!(
|
|
process
|
|
.get_args()
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
.collect::<Vec<_>>(),
|
|
["/d", "/c", "echo hello"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_command_falls_back_when_comspec_is_empty() {
|
|
let builder =
|
|
super::pane_custom_command_pty_builder_with_comspec("echo hello", Some("".into()));
|
|
|
|
assert_eq!(
|
|
argv_strings(builder.get_argv()),
|
|
[r"C:\Windows\System32\cmd.exe", "/d", "/c"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detached_custom_command_preserves_quoted_command_tail() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"herdr-raw-command-quotes-{}.txt",
|
|
std::process::id()
|
|
));
|
|
let command = format!(r#"echo "hi" > "{}""#, path.display());
|
|
|
|
let status = super::detached_custom_command_process_platform(&command)
|
|
.status()
|
|
.expect("spawn raw command");
|
|
|
|
assert!(status.success(), "{status:?}");
|
|
let content = std::fs::read_to_string(&path).expect("read command output");
|
|
let _ = std::fs::remove_file(&path);
|
|
assert!(content.contains(r#""hi""#), "{content:?}");
|
|
assert!(!content.contains(r#"\"hi\""#), "{content:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_cwd_reads_normalized_child_launch_directory() {
|
|
use std::io::{Read, Write};
|
|
use std::path::PathBuf;
|
|
|
|
let name = format!("Herdr-Cwd-Case-{}", std::process::id());
|
|
let cwd = std::env::temp_dir().join(&name);
|
|
fs::create_dir_all(&cwd).expect("create cwd fixture");
|
|
let cwd = super::normalize_cwd_for_launch_platform(&cwd);
|
|
let launch_cwd = cwd.with_file_name(name.to_ascii_lowercase());
|
|
let changed = cwd.join("Changed");
|
|
fs::create_dir(&changed).expect("create changed cwd");
|
|
let windows = PathBuf::from(std::env::var_os("SystemRoot").expect("Windows SystemRoot"));
|
|
let shells = [
|
|
windows.join("System32").join("cmd.exe"),
|
|
#[cfg(target_pointer_width = "64")]
|
|
windows.join("SysWOW64").join("cmd.exe"),
|
|
];
|
|
let mut observations = Vec::new();
|
|
for shell in shells {
|
|
let mut child = Command::new(&shell)
|
|
.args(["/D", "/Q", "/K"])
|
|
.current_dir(super::normalize_cwd_for_launch_platform(&launch_cwd))
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn cmd");
|
|
let mut stdout = super::ChildPipe::new_reader(child.stdout.take().unwrap())
|
|
.expect("read cmd output");
|
|
// The inherited cwd is readable before cmd is ready to execute input.
|
|
let mut wait_for = |marker: &[u8]| {
|
|
let deadline = Instant::now() + Duration::from_secs(15);
|
|
let mut output = Vec::new();
|
|
loop {
|
|
let mut buffer = [0; 1024];
|
|
match stdout.read(&mut buffer) {
|
|
Ok(0) => break,
|
|
Ok(len) => {
|
|
output.extend_from_slice(&buffer[..len]);
|
|
if output.windows(marker.len()).any(|part| part == marker) {
|
|
return Ok(());
|
|
}
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
|
|
Err(error) => return Err(error.to_string()),
|
|
}
|
|
if Instant::now() >= deadline {
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
Err(format!(
|
|
"cmd did not acknowledge {}: {}",
|
|
String::from_utf8_lossy(marker),
|
|
String::from_utf8_lossy(&output)
|
|
))
|
|
};
|
|
writeln!(child.stdin.as_ref().unwrap(), "echo herdr-cwd-ready\r")
|
|
.expect("check cmd readiness");
|
|
let ready = wait_for(b"herdr-cwd-ready");
|
|
let initial = super::process_cwd(child.id());
|
|
writeln!(
|
|
child.stdin.as_ref().unwrap(),
|
|
"cd /d \"{}\" 2>&1 && echo herdr-cwd-changed\r",
|
|
changed.display()
|
|
)
|
|
.expect("change cmd cwd");
|
|
let cd_completed = wait_for(b"herdr-cwd-changed");
|
|
let after_cd = super::process_cwd(child.id());
|
|
let pane_cwd = super::pane_process_cwd(child.id());
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
observations.push((shell, ready, initial, cd_completed, after_cd, pane_cwd));
|
|
}
|
|
let _ = fs::remove_dir_all(&cwd);
|
|
for (shell, ready, initial, cd_completed, after_cd, pane_cwd) in observations {
|
|
assert!(ready.is_ok(), "{} readiness: {ready:?}", shell.display());
|
|
assert_eq!(initial, Some(cwd.clone()), "{} launch cwd", shell.display());
|
|
assert!(
|
|
cd_completed.is_ok(),
|
|
"{} cd completion: {cd_completed:?}",
|
|
shell.display()
|
|
);
|
|
assert_eq!(
|
|
after_cd,
|
|
Some(changed.clone()),
|
|
"{} live cd",
|
|
shell.display()
|
|
);
|
|
assert_eq!(pane_cwd, Some(changed.clone()), "ordinary shell owns cwd");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_shim_cwd_uses_only_an_unambiguous_direct_shell() {
|
|
let cases = [
|
|
("cmdx.exe", vec![(11, 10, "cmd.exe")], Some(11)),
|
|
("pwsh.exe", vec![(11, 10, "pwsh.exe")], Some(11)),
|
|
(
|
|
"cmdx.exe",
|
|
vec![
|
|
(11, 10, "cmd.exe"),
|
|
(12, 11, "pwsh.exe"),
|
|
(13, 11, "node.exe"),
|
|
],
|
|
Some(11),
|
|
),
|
|
(
|
|
"cmdx.exe",
|
|
vec![(11, 10, "node.exe"), (12, 11, "cmd.exe")],
|
|
None,
|
|
),
|
|
("cmdx.exe", vec![], None),
|
|
(
|
|
"cmdx.exe",
|
|
vec![(11, 10, "cmd.exe"), (12, 10, "pwsh.exe")],
|
|
None,
|
|
),
|
|
];
|
|
for (root_name, children, expected) in cases {
|
|
let mut entries = vec![test_entry(10, 1, root_name, &[root_name])];
|
|
entries.extend(
|
|
children
|
|
.iter()
|
|
.map(|&(pid, parent, name)| test_entry(pid, parent, name, &[name])),
|
|
);
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
assert_eq!(
|
|
super::shim_shell_pid(10, &snapshot),
|
|
expected,
|
|
"{root_name}: {children:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_environment_reads_runtime_marker() {
|
|
let shell =
|
|
std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into());
|
|
let mut child = Command::new(shell)
|
|
.args(["/D", "/Q", "/C", "ping -n 11 127.0.0.1 > NUL"])
|
|
.env(super::PANE_RUNTIME_MARKER_ENV_VAR, "pane-test")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn cmd");
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut observed = None;
|
|
while Instant::now() < deadline {
|
|
observed = super::process_runtime_marker(child.id());
|
|
if observed.as_deref() == Some("pane-test") {
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
|
|
assert_eq!(observed.as_deref(), Some("pane-test"));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_command_line_reads_live_process_with_limited_access() {
|
|
// The point of the fix: the command line must be readable from a handle
|
|
// that does not request `PROCESS_VM_READ`. Verification against a
|
|
// process that actually denies that access needs a hardened host, which
|
|
// this suite cannot provide.
|
|
let handle = super::ProcessHandle::open(
|
|
std::process::id(),
|
|
super::PROCESS_QUERY_LIMITED_INFORMATION,
|
|
)
|
|
.expect("open self with limited access");
|
|
|
|
let command_line =
|
|
super::read_process_command_line(handle.0).expect("command line must be readable");
|
|
assert!(
|
|
!command_line.is_empty(),
|
|
"command line for the test process must not be empty"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_command_line_reads_spawned_process_marker() {
|
|
let shell =
|
|
std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into());
|
|
// `rem` keeps the marker inside cmd.exe's own command line without
|
|
// becoming a target for `ping`, so the process stays alive for the read.
|
|
let mut child = Command::new(shell)
|
|
.args([
|
|
"/D",
|
|
"/Q",
|
|
"/C",
|
|
"ping -n 11 127.0.0.1 > NUL & rem unique-cmdline-marker",
|
|
])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn cmd");
|
|
|
|
let command_line =
|
|
super::ProcessHandle::open(child.id(), super::PROCESS_QUERY_LIMITED_INFORMATION)
|
|
.and_then(|process| super::read_process_command_line(process.0));
|
|
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
|
|
let command_line = command_line.expect("command line must be readable");
|
|
assert!(
|
|
command_line.contains("unique-cmdline-marker"),
|
|
"unexpected command line: {command_line}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_selects_direct_agent_descendant() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
];
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
|
|
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&snapshot,
|
|
|_| panic!("Git Bash fallback must not run after normal detection succeeds"),
|
|
|_| panic!("runtime marker must not be read after normal detection succeeds"),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 20);
|
|
assert_eq!(job.processes.len(), 1);
|
|
assert_eq!(job.processes[0].name, "codex.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_still_inspects_unusual_escaped_argv0() {
|
|
let snapshot = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
|
test_entry(20, 99, "launcher.exe", &["codex.exe"]),
|
|
]);
|
|
|
|
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&snapshot,
|
|
|_| true,
|
|
|_| Some("pane-a".to_string()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 20);
|
|
assert_eq!(job.processes[0].name, "launcher.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_still_inspects_unusual_descendant_argv0() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "launcher.exe", &["codex.exe"]),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 20);
|
|
assert_eq!(job.processes[0].name, "launcher.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_shares_snapshot_candidates_across_git_bash_panes() {
|
|
let snapshot = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
|
test_entry(
|
|
11,
|
|
10,
|
|
"bash.exe",
|
|
&[r"C:\Program Files\Git\usr\bin\bash.exe"],
|
|
),
|
|
test_entry(12, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
|
test_entry(
|
|
20,
|
|
99,
|
|
"sh.exe",
|
|
&[r"C:\Program Files\Git\usr\bin\sh.exe", "/c/npm/codex"],
|
|
),
|
|
test_entry(
|
|
30,
|
|
20,
|
|
"node.exe",
|
|
&[
|
|
r"C:\Program Files\nodejs\node.exe",
|
|
r"C:\Users\user\AppData\Roaming\npm\node_modules\@openai\codex\bin\codex.js",
|
|
],
|
|
),
|
|
test_entry(
|
|
40,
|
|
30,
|
|
"codex.exe",
|
|
&[r"C:\npm\node_modules\@openai\codex\bin\codex.exe"],
|
|
),
|
|
test_entry(50, 98, "claude.exe", &["claude.exe"]),
|
|
]);
|
|
let mut inspected = Vec::new();
|
|
assert!(snapshot.agent_indices.get().is_none());
|
|
let marker = |entry: &super::WindowsProcessEntry| match entry.pid {
|
|
12 | 50 => Some("pane-b".to_string()),
|
|
_ => Some("pane-a".to_string()),
|
|
};
|
|
|
|
let first = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&snapshot,
|
|
|_| true,
|
|
|entry| {
|
|
inspected.push(entry.pid);
|
|
marker(entry)
|
|
},
|
|
)
|
|
.unwrap();
|
|
let indices = snapshot.agent_indices.get().unwrap();
|
|
let second = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
12,
|
|
&snapshot,
|
|
|_| true,
|
|
marker,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(first.process_group_id, 20);
|
|
assert_eq!(first.processes[0].name, "sh.exe");
|
|
assert_eq!(second.process_group_id, 50);
|
|
assert_eq!(indices, &[3, 4, 5, 6]);
|
|
assert!(std::ptr::eq(indices, snapshot.agent_indices.get().unwrap()));
|
|
assert_eq!(inspected, vec![10, 20, 30, 40, 50]);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_skips_runtime_inspection_for_non_git_bash_shell() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
|
];
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
|
|
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&snapshot,
|
|
|_| false,
|
|
|_| panic!("runtime marker must not be read for non-Git-Bash panes"),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_skips_runtime_inspection_without_agent_candidate() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
|
test_entry(20, 99, "git.exe", &["git.exe", "status"]),
|
|
];
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
|
|
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&snapshot,
|
|
|_| true,
|
|
|_| panic!("runtime marker must not be read without an agent candidate"),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_rejects_missing_or_empty_shell_runtime_marker() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
|
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
|
];
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
|
|
for shell_marker in [None, Some(String::new())] {
|
|
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&snapshot,
|
|
|_| true,
|
|
|entry| {
|
|
if entry.pid == 10 {
|
|
shell_marker.clone()
|
|
} else {
|
|
Some("pane-a".to_string())
|
|
}
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_rejects_runtime_marker_from_another_pane() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
|
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
|
];
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
|
|
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&snapshot,
|
|
|_| true,
|
|
|entry| Some(if entry.pid == 10 { "pane-a" } else { "pane-b" }.to_string()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
assert_eq!(job.processes[0].name, "bash.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_held_agent_must_still_belong_to_the_pane() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
|
test_entry(11, 10, "claude.exe", &["claude.exe"]),
|
|
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 98, "vim.exe", &["vim.exe"]),
|
|
test_entry(50, 77, "claude.exe", &["claude.exe"]),
|
|
];
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
let marker = |pane: &'static str| {
|
|
move |entry: &super::WindowsProcessEntry| {
|
|
Some(if entry.pid == 10 { "pane-a" } else { pane }.to_string())
|
|
}
|
|
};
|
|
let belongs = |pid, git_bash, pane| {
|
|
super::process_belongs_to_pane(10, pid, &snapshot, |_| git_bash, marker(pane))
|
|
};
|
|
|
|
assert!(belongs(11, false, "pane-b"), "descendant of the shell");
|
|
assert!(
|
|
belongs(20, true, "pane-a"),
|
|
"escaped agent with the pane marker"
|
|
);
|
|
assert!(!belongs(20, true, "pane-b"), "marker from another pane");
|
|
assert!(
|
|
!belongs(20, false, "pane-a"),
|
|
"escape only applies to Git Bash"
|
|
);
|
|
assert!(!belongs(30, true, "pane-a"), "escaped non-agent process");
|
|
assert!(
|
|
!belongs(40, true, "pane-a"),
|
|
"process gone from the snapshot"
|
|
);
|
|
assert!(
|
|
!belongs(50, false, "pane-a"),
|
|
"agent whose parent chain no longer reaches the shell"
|
|
);
|
|
assert!(
|
|
!super::process_belongs_to_pane(60, 11, &snapshot, |_| true, marker("pane-a")),
|
|
"pane shell gone"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_rejects_ambiguous_runtime_marker_candidates() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
|
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 98, "claude.exe", &["claude.exe"]),
|
|
];
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
|
|
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&snapshot,
|
|
|_| true,
|
|
|_| Some("pane-a".to_string()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
assert_eq!(job.processes[0].name, "bash.exe");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "isolated 1/15/118-shell process-inspection profile"]
|
|
fn windows_process_inspection_profile() {
|
|
struct Shell {
|
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
|
pty: Option<portable_pty::PtyPair>,
|
|
reader: Option<thread::JoinHandle<()>>,
|
|
}
|
|
impl Drop for Shell {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
self.pty.take();
|
|
if let Some(reader) = self.reader.take() {
|
|
let _ = reader.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cpu_time() -> Duration {
|
|
let mut creation = super::FILETIME::default();
|
|
let mut exit = super::FILETIME::default();
|
|
let mut kernel = super::FILETIME::default();
|
|
let mut user = super::FILETIME::default();
|
|
assert_ne!(
|
|
unsafe {
|
|
super::GetProcessTimes(
|
|
super::GetCurrentProcess(),
|
|
&mut creation,
|
|
&mut exit,
|
|
&mut kernel,
|
|
&mut user,
|
|
)
|
|
},
|
|
0
|
|
);
|
|
let ticks = |time: super::FILETIME| {
|
|
(u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)
|
|
};
|
|
Duration::from_nanos((ticks(kernel) + ticks(user)) * 100)
|
|
}
|
|
|
|
let shell =
|
|
std::path::PathBuf::from(std::env::var_os("SystemRoot").expect("Windows SystemRoot"))
|
|
.join("System32")
|
|
.join("cmd.exe");
|
|
for panes in [1, 15, 118] {
|
|
let mut shells = Vec::new();
|
|
for _ in 0..panes {
|
|
let pty = portable_pty::native_pty_system()
|
|
.openpty(portable_pty::PtySize {
|
|
rows: 24,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.expect("open isolated fixed-geometry PTY");
|
|
let mut command = portable_pty::CommandBuilder::new(&shell);
|
|
command.args(["/D", "/Q", "/K"]);
|
|
let child = pty
|
|
.slave
|
|
.spawn_command(command)
|
|
.expect("spawn isolated idle shell");
|
|
let mut reader = pty
|
|
.master
|
|
.try_clone_reader()
|
|
.expect("clone profile PTY reader");
|
|
shells.push(Shell {
|
|
child,
|
|
pty: Some(pty),
|
|
reader: Some(thread::spawn(move || {
|
|
let _ = std::io::copy(&mut reader, &mut std::io::sink());
|
|
})),
|
|
});
|
|
}
|
|
thread::sleep(Duration::from_millis(300));
|
|
let snapshot = super::ProcessSnapshot::new(super::snapshot_processes());
|
|
for shell in &shells {
|
|
assert!(
|
|
super::descendant_entries(shell.child.process_id().unwrap(), &snapshot)
|
|
.is_empty()
|
|
);
|
|
}
|
|
for sample in 0..3 {
|
|
super::FOREGROUND_PROCESS_SNAPSHOT_CACHE
|
|
.lock()
|
|
.unwrap()
|
|
.cached = None;
|
|
super::FOREGROUND_SELECTION_CACHE
|
|
.lock()
|
|
.unwrap()
|
|
.entries
|
|
.clear();
|
|
super::PROCESS_INSPECTION_COUNTS
|
|
.with_borrow_mut(|counts| *counts = super::ProcessInspectionCounts::default());
|
|
let started = Instant::now();
|
|
let cpu_started = cpu_time();
|
|
let mut inspection_time = Duration::ZERO;
|
|
for poll in 0..20 {
|
|
let next_poll = started + Duration::from_millis(poll * 500);
|
|
thread::sleep(next_poll.saturating_duration_since(Instant::now()));
|
|
let inspecting = Instant::now();
|
|
for shell in &mut shells {
|
|
assert!(shell.child.try_wait().unwrap().is_none());
|
|
let pid = shell.child.process_id().unwrap();
|
|
let job = super::foreground_job(pid).expect("live shell job");
|
|
assert_eq!(job.process_group_id, pid);
|
|
}
|
|
inspection_time += inspecting.elapsed();
|
|
}
|
|
let cpu = cpu_time() - cpu_started;
|
|
let counts = super::PROCESS_INSPECTION_COUNTS.with_borrow(|counts| *counts);
|
|
println!(
|
|
"panes={panes} sample={sample} polls=20 snapshots={} opens={} command_reads={} inspection_ms={:.3} cpu_ms={:.3} elapsed_ms={:.3}",
|
|
counts.snapshots,
|
|
counts.opens,
|
|
counts.command_reads,
|
|
inspection_time.as_secs_f64() * 1000.0,
|
|
cpu.as_secs_f64() * 1000.0,
|
|
started.elapsed().as_secs_f64() * 1000.0,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_process_snapshot_is_shared_within_ttl() {
|
|
let mut cache = super::ProcessSnapshotCache { cached: None };
|
|
let mut builds = 0;
|
|
let mut first_build_completed_at = None;
|
|
|
|
let first = cache.snapshot(Duration::from_secs(60), || {
|
|
builds += 1;
|
|
let entries = vec![test_entry(10, 1, "powershell.exe", &["powershell.exe"])];
|
|
first_build_completed_at = Some(Instant::now());
|
|
entries
|
|
});
|
|
assert!(cache.cached.as_ref().unwrap().built_at >= first_build_completed_at.unwrap());
|
|
let second = cache.snapshot(Duration::from_secs(60), || {
|
|
builds += 1;
|
|
Vec::new()
|
|
});
|
|
let refreshed = cache.snapshot(Duration::ZERO, || {
|
|
builds += 1;
|
|
vec![test_entry(20, 1, "pwsh.exe", &["pwsh.exe"])]
|
|
});
|
|
|
|
assert!(Arc::ptr_eq(&first, &second));
|
|
assert!(!Arc::ptr_eq(&second, &refreshed));
|
|
assert_eq!(builds, 2);
|
|
assert_eq!(refreshed.entries[0].pid, 20);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_selection_cache_reuses_live_agent_and_invalidates_changes() {
|
|
let snapshot = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
]);
|
|
let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap());
|
|
let mut cache = super::ForegroundSelectionCache::default();
|
|
cache.remember_for_test(10, &snapshot, &job);
|
|
|
|
assert_eq!(cache.get(10, &snapshot), Some(job.clone()));
|
|
|
|
cache.entries.get_mut(&10).unwrap().selected_identity = super::ProcessIdentity::Stub {
|
|
running: false,
|
|
creation_time: None,
|
|
};
|
|
assert_eq!(cache.get(10, &snapshot), None);
|
|
|
|
cache.remember_for_test(10, &snapshot, &job);
|
|
let overlap = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 10, "claude.exe", &["claude.exe"]),
|
|
]);
|
|
assert_eq!(cache.get(10, &overlap), None);
|
|
|
|
cache.remember_for_test(10, &snapshot, &job);
|
|
let changed = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "git.exe", &["git.exe"]),
|
|
]);
|
|
assert_eq!(cache.get(10, &changed), None);
|
|
|
|
cache.remember_for_test(10, &snapshot, &job);
|
|
cache.entries.get_mut(&10).unwrap().verified_at = Instant::now()
|
|
.checked_sub(super::FOREGROUND_SELECTION_RECHECK + Duration::from_secs(1))
|
|
.unwrap();
|
|
assert_eq!(cache.get(10, &snapshot), None);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_selection_cache_rejects_reused_descendant_pid() {
|
|
let original = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 10, "node.exe", &["node.exe", "worker.js"]),
|
|
]);
|
|
let job = super::foreground_job_from_entry(original.entry(20).unwrap());
|
|
let mut cache = super::ForegroundSelectionCache::default();
|
|
cache.remember_for_test(10, &original, &job);
|
|
|
|
let cached = cache.entries.get_mut(&10).unwrap();
|
|
let reused_index = cached
|
|
.descendants
|
|
.iter()
|
|
.position(|entry| entry.pid == 30)
|
|
.unwrap();
|
|
cached.descendant_identities[reused_index] = super::ProcessIdentity::Stub {
|
|
running: false,
|
|
creation_time: None,
|
|
};
|
|
let replacement = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 10, "node.exe", &["node.exe", "codex.js"]),
|
|
]);
|
|
|
|
assert_eq!(cache.get(10, &replacement), None);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_selection_cache_rejects_identity_change_before_insertion() {
|
|
let snapshot = super::ProcessSnapshot::new(vec![
|
|
test_entry_with_creation_time(10, 1, "powershell.exe", &["powershell.exe"], Some(1)),
|
|
test_entry_with_creation_time(20, 10, "codex.exe", &["codex.exe"], Some(2)),
|
|
test_entry_with_creation_time(30, 10, "node.exe", &["node.exe", "worker.js"], Some(3)),
|
|
]);
|
|
let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap());
|
|
let descendants = snapshot.descendant_signatures(10);
|
|
let descendant_identities = vec![
|
|
super::ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: Some(2),
|
|
},
|
|
super::ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: Some(4),
|
|
},
|
|
];
|
|
let mut cache = super::ForegroundSelectionCache::default();
|
|
|
|
let cached = super::CachedForegroundSelection::from_snapshot_with_identities(
|
|
10,
|
|
&snapshot,
|
|
&job,
|
|
descendants,
|
|
descendant_identities,
|
|
super::ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: Some(1),
|
|
},
|
|
super::ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: Some(2),
|
|
},
|
|
);
|
|
cache.remember(10, cached);
|
|
|
|
assert!(cache.entries.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_selection_cache_accepts_limited_information_identity() {
|
|
let snapshot = super::ProcessSnapshot::new(vec![
|
|
test_entry_without_cmdline(10, 1, "powershell.exe", 1),
|
|
test_entry_without_cmdline(20, 10, "codex.exe", 2),
|
|
]);
|
|
let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap());
|
|
let cached = super::CachedForegroundSelection::from_snapshot_with_identities(
|
|
10,
|
|
&snapshot,
|
|
&job,
|
|
snapshot.descendant_signatures(10),
|
|
vec![super::ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: Some(2),
|
|
}],
|
|
super::ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: Some(1),
|
|
},
|
|
super::ProcessIdentity::Stub {
|
|
running: true,
|
|
creation_time: Some(2),
|
|
},
|
|
);
|
|
|
|
assert!(cached.is_some());
|
|
assert_eq!(job.processes[0].argv0.as_deref(), Some("codex.exe"));
|
|
assert!(job.processes[0].cmdline.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_selection_cache_prunes_unused_entries_on_insertion() {
|
|
let first_snapshot = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
]);
|
|
let first_job = super::foreground_job_from_entry(first_snapshot.entry(20).unwrap());
|
|
let mut cache = super::ForegroundSelectionCache::default();
|
|
cache.remember_for_test(10, &first_snapshot, &first_job);
|
|
cache.entries.get_mut(&10).unwrap().last_used = Instant::now()
|
|
.checked_sub(super::FOREGROUND_SELECTION_CACHE_RETENTION + Duration::from_secs(1))
|
|
.unwrap();
|
|
|
|
let second_snapshot = super::ProcessSnapshot::new(vec![
|
|
test_entry(11, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(21, 11, "claude.exe", &["claude.exe"]),
|
|
]);
|
|
let second_job = super::foreground_job_from_entry(second_snapshot.entry(21).unwrap());
|
|
cache.remember_for_test(11, &second_snapshot, &second_job);
|
|
|
|
assert!(!cache.entries.contains_key(&10));
|
|
assert!(cache.entries.contains_key(&11));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_selection_cache_retains_idle_native_shell_until_launch_or_exit() {
|
|
for name in ["cmd.exe", "powershell.exe", "PWSH.EXE"] {
|
|
let snapshot = super::ProcessSnapshot::new(vec![test_entry(10, 1, name, &[name])]);
|
|
let shell = super::foreground_job_from_entry(snapshot.entry(10).unwrap());
|
|
let mut cache = super::ForegroundSelectionCache::default();
|
|
cache.remember_for_test(10, &snapshot, &shell);
|
|
assert_eq!(cache.get(10, &snapshot), Some(shell.clone()));
|
|
|
|
let launched = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, name, &[name]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
]);
|
|
assert_eq!(cache.get(10, &launched), None);
|
|
let agent = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
|
|
10,
|
|
&launched,
|
|
|_| panic!("direct child must bypass escaped-agent inspection"),
|
|
|_| panic!("direct child must bypass runtime markers"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(agent.process_group_id, 20);
|
|
cache.remember_for_test(10, &launched, &agent);
|
|
assert_eq!(cache.get(10, &snapshot), None);
|
|
|
|
cache.remember_for_test(10, &snapshot, &shell);
|
|
assert_eq!(cache.get(10, &snapshot), Some(shell.clone()));
|
|
let short_lived_child = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, name, &[name]),
|
|
test_entry(21, 10, "git.exe", &["git.exe"]),
|
|
]);
|
|
assert_eq!(cache.get(10, &short_lived_child), None);
|
|
cache.remember_for_test(10, &snapshot, &shell);
|
|
cache.entries.get_mut(&10).unwrap().shell_identity = super::ProcessIdentity::Stub {
|
|
running: false,
|
|
creation_time: None,
|
|
};
|
|
// An identical fresh signature must not hide shell exit/PID reuse.
|
|
assert_eq!(cache.get(10, &snapshot), None);
|
|
|
|
cache.remember_for_test(10, &snapshot, &shell);
|
|
cache.entries.get_mut(&10).unwrap().verified_at = Instant::now()
|
|
.checked_sub(super::FOREGROUND_SELECTION_RECHECK + Duration::from_secs(1))
|
|
.unwrap();
|
|
assert_eq!(cache.get(10, &snapshot), None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_selection_cache_keeps_escaped_and_unknown_children_fresh() {
|
|
for entries in [
|
|
vec![test_entry(10, 1, "bash.exe", &["bash.exe"])],
|
|
vec![test_entry(10, 1, "launcher.exe", &["launcher.exe"])],
|
|
vec![
|
|
test_entry(10, 1, "pwsh.exe", &["pwsh.exe"]),
|
|
test_entry(20, 10, "node.exe", &["node.exe", "worker.js"]),
|
|
],
|
|
] {
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
let shell = super::foreground_job_from_entry(snapshot.entry(10).unwrap());
|
|
let mut cache = super::ForegroundSelectionCache::default();
|
|
cache.remember_for_test(10, &snapshot, &shell);
|
|
assert_eq!(cache.get(10, &snapshot), None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_selection_cache_retains_live_escaped_agent() {
|
|
let snapshot = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "bash.exe", &["bash.exe"]),
|
|
test_entry(20, 99, "launcher.exe", &["codex.exe"]),
|
|
]);
|
|
let escaped = super::foreground_job_from_entry(snapshot.entry(20).unwrap());
|
|
let mut cache = super::ForegroundSelectionCache::default();
|
|
|
|
cache.remember_for_test(10, &snapshot, &escaped);
|
|
|
|
assert_eq!(cache.get(10, &snapshot), Some(escaped.clone()));
|
|
cache.entries.get_mut(&10).unwrap().selected_identity = super::ProcessIdentity::Stub {
|
|
running: false,
|
|
creation_time: None,
|
|
};
|
|
assert_eq!(cache.get(10, &snapshot), None);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_selects_wrapped_agent_descendant() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "cmd.exe", &["cmd.exe"]),
|
|
test_entry(
|
|
20,
|
|
10,
|
|
"node.exe",
|
|
&[
|
|
"node.exe",
|
|
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\codex\\bin\\codex.js",
|
|
],
|
|
),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 20);
|
|
assert_eq!(job.processes[0].name, "node.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_selects_cmd_wrapped_agent_descendant() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(
|
|
20,
|
|
10,
|
|
"cmd.exe",
|
|
&[
|
|
"cmd.exe",
|
|
"/D",
|
|
"/S",
|
|
"/C",
|
|
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\codex.cmd --model gpt-5",
|
|
],
|
|
),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 20);
|
|
assert_eq!(job.processes[0].name, "cmd.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_selects_topmost_codex_process_in_single_agent_chain() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(
|
|
20,
|
|
10,
|
|
"node.exe",
|
|
&[
|
|
"node.exe",
|
|
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js",
|
|
],
|
|
),
|
|
test_entry(
|
|
30,
|
|
20,
|
|
"codex.exe",
|
|
&["C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe"],
|
|
),
|
|
test_entry(40, 30, "node_repl.exe", &["node_repl.exe"]),
|
|
test_entry(
|
|
50,
|
|
40,
|
|
"codex.exe",
|
|
&["codex.exe", "app-server", "--listen", "stdio://"],
|
|
),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 20);
|
|
assert_eq!(job.processes[0].name, "node.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_keeps_topmost_agent_over_different_agent_descendant() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "claude.exe", &["claude.exe"]),
|
|
test_entry(
|
|
30,
|
|
20,
|
|
"cmd.exe",
|
|
&["cmd.exe", "/D", "/S", "/C", "codex mcp-server"],
|
|
),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 20);
|
|
assert_eq!(job.processes[0].name, "claude.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_keeps_root_agent_over_agent_descendant() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "claude.exe", &["claude.exe"]),
|
|
test_entry(
|
|
20,
|
|
10,
|
|
"cmd.exe",
|
|
&["cmd.exe", "/D", "/S", "/C", "codex mcp-server"],
|
|
),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
assert_eq!(job.processes[0].name, "claude.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_returns_shell_for_same_agent_siblings() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 10, "codex.exe", &["codex.exe"]),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
assert_eq!(job.processes[0].name, "powershell.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_returns_shell_for_plain_descendant() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "git.exe", &["git.exe", "status"]),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
assert_eq!(job.processes[0].name, "powershell.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_shell_is_available_only_without_descendants() {
|
|
let shell_only = super::ProcessSnapshot::new(vec![test_entry(
|
|
10,
|
|
1,
|
|
"powershell.exe",
|
|
&["powershell.exe"],
|
|
)]);
|
|
assert_eq!(
|
|
super::available_pane_shell_from_snapshot(10, &shell_only).as_deref(),
|
|
Some("powershell.exe")
|
|
);
|
|
|
|
let busy = super::ProcessSnapshot::new(vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "git.exe", &["git.exe", "status"]),
|
|
]);
|
|
assert_eq!(super::available_pane_shell_from_snapshot(10, &busy), None);
|
|
|
|
let replaced =
|
|
super::ProcessSnapshot::new(vec![test_entry(10, 1, "vim.exe", &["vim.exe"])]);
|
|
assert_eq!(
|
|
super::available_pane_shell_from_snapshot(10, &replaced),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_returns_shell_for_multiple_agent_descendants() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 10, "claude.exe", &["claude.exe"]),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
assert_eq!(job.processes[0].name, "powershell.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_session_processes_collects_shell_and_descendants() {
|
|
let entries = vec![
|
|
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "cmd.exe", &["cmd.exe"]),
|
|
test_entry(30, 20, "node.exe", &["node.exe"]),
|
|
test_entry(40, 1, "unrelated.exe", &["unrelated.exe"]),
|
|
];
|
|
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
let mut pids = super::session_processes_from_snapshot(10, &snapshot);
|
|
pids.sort_unstable();
|
|
|
|
assert_eq!(pids, vec![10, 20, 30]);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_ignores_pid_reuse_cycles() {
|
|
let entries = vec![
|
|
test_entry(10, 30, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 20, "node.exe", &["node.exe"]),
|
|
];
|
|
|
|
let snapshot = super::ProcessSnapshot::new(entries);
|
|
let descendants = super::descendant_entries(10, &snapshot);
|
|
|
|
assert_eq!(
|
|
descendants
|
|
.iter()
|
|
.map(|entry| entry.pid)
|
|
.collect::<Vec<_>>(),
|
|
vec![20, 30]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_process_tree_returns_shell_when_candidate_parent_chain_cycles() {
|
|
let entries = vec![
|
|
test_entry(10, 40, "powershell.exe", &["powershell.exe"]),
|
|
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
|
test_entry(30, 10, "codex.exe", &["codex.exe"]),
|
|
test_entry(40, 10, "node.exe", &["node.exe"]),
|
|
];
|
|
|
|
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
|
|
|
assert_eq!(job.process_group_id, 10);
|
|
assert_eq!(job.processes[0].name, "powershell.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn scrollback_editor_argv_uses_editor_env_and_appends_path() {
|
|
let path = std::path::Path::new(r"C:\Users\User\AppData\Local\Temp\herdr scrollback.txt");
|
|
let argv = super::scrollback_editor_argv_with_env(
|
|
path,
|
|
Some(r#""C:\Program Files\Microsoft VS Code\Code.exe" --wait"#),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(argv[0], r"C:\Program Files\Microsoft VS Code\Code.exe");
|
|
assert_eq!(argv[1], "--wait");
|
|
assert_eq!(argv[2], path.display().to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn scrollback_editor_argv_falls_back_to_notepad() {
|
|
let path = std::path::Path::new(r"C:\Temp\herdr-scrollback.txt");
|
|
let argv = super::scrollback_editor_argv_with_env(path, None).unwrap();
|
|
|
|
assert_eq!(
|
|
argv,
|
|
vec!["notepad.exe".to_string(), path.display().to_string()]
|
|
);
|
|
}
|
|
|
|
fn test_entry(
|
|
pid: u32,
|
|
parent_pid: u32,
|
|
name: &str,
|
|
argv: &[&str],
|
|
) -> super::WindowsProcessEntry {
|
|
test_entry_with_creation_time(pid, parent_pid, name, argv, None)
|
|
}
|
|
|
|
fn test_entry_without_cmdline(
|
|
pid: u32,
|
|
parent_pid: u32,
|
|
name: &str,
|
|
creation_time: u64,
|
|
) -> super::WindowsProcessEntry {
|
|
let command = super::OnceLock::new();
|
|
command
|
|
.set(super::WindowsProcessCommand::from_cmdline(
|
|
name,
|
|
Some(creation_time),
|
|
None,
|
|
))
|
|
.unwrap();
|
|
super::WindowsProcessEntry {
|
|
pid,
|
|
parent_pid,
|
|
name: name.to_string(),
|
|
command,
|
|
}
|
|
}
|
|
|
|
fn test_entry_with_creation_time(
|
|
pid: u32,
|
|
parent_pid: u32,
|
|
name: &str,
|
|
argv: &[&str],
|
|
creation_time: Option<u64>,
|
|
) -> super::WindowsProcessEntry {
|
|
let command = super::OnceLock::new();
|
|
command
|
|
.set(super::WindowsProcessCommand {
|
|
creation_time,
|
|
argv0: argv.first().map(|value| (*value).to_string()),
|
|
argv: Some(argv.iter().map(|value| (*value).to_string()).collect()),
|
|
cmdline: Some(argv.join(" ")),
|
|
})
|
|
.unwrap();
|
|
super::WindowsProcessEntry {
|
|
pid,
|
|
parent_pid,
|
|
name: name.to_string(),
|
|
command,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_environment_variable_parser_reads_case_insensitive_marker() {
|
|
let environment: Vec<u16> = "PATH=C:\\Windows\0herdr_pane_runtime_id=pane-a\0\0"
|
|
.encode_utf16()
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
super::environment_variable_from_utf16(
|
|
&environment,
|
|
super::PANE_RUNTIME_MARKER_ENV_VAR,
|
|
)
|
|
.as_deref(),
|
|
Some("pane-a")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pane_runtime_markers_are_distinct() {
|
|
let first = super::next_pane_runtime_marker();
|
|
let second = super::next_pane_runtime_marker();
|
|
|
|
assert_ne!(first, second);
|
|
}
|
|
|
|
#[test]
|
|
fn pane_runtime_marker_is_added_only_to_git_bash_environment() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"herdr-git-bash-test-{}",
|
|
super::next_pane_runtime_marker()
|
|
));
|
|
fs::create_dir_all(root.join("bin")).expect("create Git Bash bin fixture");
|
|
fs::create_dir_all(root.join("usr").join("bin")).expect("create Git Bash usr/bin fixture");
|
|
fs::create_dir_all(root.join("cmd")).expect("create Git Bash cmd fixture");
|
|
fs::write(root.join("bin").join("bash.exe"), []).expect("create Bash fixture");
|
|
fs::write(root.join("usr").join("bin").join("msys-2.0.dll"), [])
|
|
.expect("create MSYS runtime fixture");
|
|
fs::write(root.join("cmd").join("git.exe"), []).expect("create Git fixture");
|
|
|
|
let mut git_bash = portable_pty::CommandBuilder::new(root.join("bin").join("bash.exe"));
|
|
super::apply_pane_runtime_marker_platform(&mut git_bash);
|
|
let mut path_resolved_git_bash = portable_pty::CommandBuilder::new("bash.exe");
|
|
path_resolved_git_bash.env("PATH", root.join("bin"));
|
|
super::apply_pane_runtime_marker_platform(&mut path_resolved_git_bash);
|
|
let mut cmd = portable_pty::CommandBuilder::new("cmd.exe");
|
|
super::apply_pane_runtime_marker_platform(&mut cmd);
|
|
|
|
assert!(git_bash
|
|
.get_env(super::PANE_RUNTIME_MARKER_ENV_VAR)
|
|
.is_some_and(|value| !value.is_empty()));
|
|
assert!(path_resolved_git_bash
|
|
.get_env(super::PANE_RUNTIME_MARKER_ENV_VAR)
|
|
.is_some_and(|value| !value.is_empty()));
|
|
assert!(cmd.get_env(super::PANE_RUNTIME_MARKER_ENV_VAR).is_none());
|
|
fs::remove_dir_all(root).expect("remove Git Bash fixture");
|
|
}
|
|
|
|
#[test]
|
|
fn ime_open_reflects_open_status() {
|
|
// IMC_GETOPENSTATUS returns nonzero when the IME is open (Hangul
|
|
// composing) and zero for direct English/ASCII input.
|
|
assert!(super::ime_open(1));
|
|
assert!(!super::ime_open(0));
|
|
// Any nonzero value is treated as open, not just 1.
|
|
assert!(super::ime_open(2));
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_key_maps_korean_and_ignores_other_languages() {
|
|
// Korean (0x0412) -> Hangul/English toggle.
|
|
assert_eq!(
|
|
super::toggle_key_for_language(0x0412),
|
|
Some(super::VK_HANGUL)
|
|
);
|
|
// Korean with a different sublanguage still resolves by primary id.
|
|
assert_eq!(
|
|
super::toggle_key_for_language(0x0812),
|
|
Some(super::VK_HANGUL)
|
|
);
|
|
// Japanese (0x0411) and Chinese (0x0804) have no mapped key yet.
|
|
assert_eq!(super::toggle_key_for_language(0x0411), None);
|
|
assert_eq!(super::toggle_key_for_language(0x0804), None);
|
|
// English (0x0409): nothing to toggle.
|
|
assert_eq!(super::toggle_key_for_language(0x0409), None);
|
|
}
|
|
|
|
#[test]
|
|
fn send_vk_tap_reports_success_when_full_tap_is_queued() {
|
|
let mut calls = 0;
|
|
let ok = super::send_vk_tap_with(super::VK_HANGUL, |events| {
|
|
calls += 1;
|
|
events.len() as u32
|
|
});
|
|
assert!(ok, "a fully queued tap is reported as success");
|
|
assert_eq!(calls, 1, "a clean tap needs no retry");
|
|
}
|
|
|
|
#[test]
|
|
fn send_vk_tap_retries_keyup_and_reports_toggle_on_partial_injection() {
|
|
let mut calls = 0;
|
|
let mut retry_len = 0;
|
|
let mut retry_is_keyup = false;
|
|
let ok = super::send_vk_tap_with(super::VK_HANGUL, |events| {
|
|
calls += 1;
|
|
if calls == 1 {
|
|
// Only the key-down is queued; the key-up is dropped.
|
|
1
|
|
} else {
|
|
retry_len = events.len();
|
|
// SAFETY: keyboard inputs, so reading the `ki` union is valid.
|
|
retry_is_keyup =
|
|
unsafe { events[0].Anonymous.ki.dwFlags } == super::KEYEVENTF_KEYUP;
|
|
events.len() as u32
|
|
}
|
|
});
|
|
assert!(ok, "the queued key-down may have toggled the IME");
|
|
assert_eq!(calls, 2, "the dropped key-up is retried exactly once");
|
|
assert_eq!(retry_len, 1, "only the key-up is retried");
|
|
assert!(retry_is_keyup, "the retry injects the key-up event");
|
|
}
|
|
|
|
#[test]
|
|
fn send_vk_tap_reports_toggle_when_keyup_retry_fails() {
|
|
let mut calls = 0;
|
|
let ok = super::send_vk_tap_with(super::VK_HANGUL, |_events| {
|
|
calls += 1;
|
|
if calls == 1 {
|
|
1
|
|
} else {
|
|
0
|
|
}
|
|
});
|
|
assert!(ok, "the queued key-down may have toggled the IME");
|
|
assert_eq!(calls, 2, "the dropped key-up is retried exactly once");
|
|
}
|
|
|
|
#[test]
|
|
fn send_vk_tap_reports_failure_without_retry_when_nothing_is_queued() {
|
|
let mut calls = 0;
|
|
let ok = super::send_vk_tap_with(super::VK_HANGUL, |_events| {
|
|
calls += 1;
|
|
0
|
|
});
|
|
assert!(!ok, "a fully blocked tap is reported as failure");
|
|
assert_eq!(
|
|
calls, 1,
|
|
"nothing was queued, so there is no key-up to retry"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn key_tap_inputs_emit_keydown_then_keyup() {
|
|
let inputs = super::key_tap_inputs(super::VK_HANGUL);
|
|
// SAFETY: both entries are keyboard inputs, so reading the `ki` union is valid.
|
|
unsafe {
|
|
assert_eq!(inputs[0].r#type, super::INPUT_KEYBOARD);
|
|
assert_eq!(inputs[0].Anonymous.ki.wVk, super::VK_HANGUL);
|
|
assert_eq!(inputs[0].Anonymous.ki.dwFlags, 0, "first event is key-down");
|
|
assert_eq!(inputs[1].r#type, super::INPUT_KEYBOARD);
|
|
assert_eq!(inputs[1].Anonymous.ki.wVk, super::VK_HANGUL);
|
|
assert_eq!(
|
|
inputs[1].Anonymous.ki.dwFlags,
|
|
super::KEYEVENTF_KEYUP,
|
|
"second event is key-up"
|
|
);
|
|
}
|
|
}
|
|
}
|