use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::io::AsRawFd;
use std::sync::OnceLock;
use anyhow::{Context, Result};
use tracing::{debug, warn};
use crate::utils::keys::tmux_key_to_bytes;
fn is_pty_slave(fd_path: &str) -> bool {
match fs::read_link(fd_path) {
Ok(target) => {
let s = target.to_string_lossy();
s.starts_with("/dev/pts/")
}
Err(_) => false,
}
}
fn inject_via_tiocsti(fd: std::os::unix::io::RawFd, data: &[u8]) -> Result<()> {
for &byte in data {
let ret = unsafe { libc::ioctl(fd, libc::TIOCSTI, &byte as *const u8) };
if ret < 0 {
let err = std::io::Error::last_os_error();
return Err(anyhow::anyhow!(
"TIOCSTI ioctl failed (legacy_tiocsti may be disabled): {}",
err
));
}
}
Ok(())
}
pub fn inject_keys(pid: u32, data: &[u8]) -> Result<()> {
let fd_path = format!("/proc/{}/fd/0", pid);
if is_pty_slave(&fd_path) {
let file = OpenOptions::new()
.read(true)
.write(true)
.open(&fd_path)
.with_context(|| format!("Failed to open {} for TIOCSTI injection", fd_path))?;
match inject_via_tiocsti(file.as_raw_fd(), data) {
Ok(()) => {
debug!(
pid,
bytes = data.len(),
"PTY inject via TIOCSTI: wrote bytes"
);
Ok(())
}
Err(e) => {
warn!(
pid,
"PTY inject: TIOCSTI failed (fd is PTY slave, direct write would go output direction). \
Falling back to Tier 3. Error: {}", e
);
Err(e)
}
}
} else {
let mut file = OpenOptions::new()
.write(true)
.open(&fd_path)
.with_context(|| format!("Failed to open {} for pipe injection", fd_path))?;
file.write_all(data)
.with_context(|| format!("Failed to write to {}", fd_path))?;
file.flush()?;
debug!(
pid,
bytes = data.len(),
"PTY inject via pipe write: wrote bytes"
);
Ok(())
}
}
pub fn inject_text(pid: u32, keys: &str) -> Result<()> {
let data = tmux_key_to_bytes(keys);
inject_keys(pid, &data)
}
pub fn inject_text_literal(pid: u32, text: &str) -> Result<()> {
inject_keys(pid, text.as_bytes())
}
pub fn inject_text_and_enter(pid: u32, text: &str) -> Result<()> {
let mut data = text.as_bytes().to_vec();
data.push(b'\r');
inject_keys(pid, &data)
}
pub fn is_tiocsti_available() -> bool {
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
match fs::read_to_string("/proc/sys/dev/tty/legacy_tiocsti") {
Ok(content) => content.trim() == "1",
Err(_) => true,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inject_keys_nonexistent_pid() {
let result = inject_keys(999_999_999, b"test");
assert!(result.is_err());
}
#[test]
fn test_inject_text_converts_key_names() {
let result = inject_text(999_999_999, "Enter");
assert!(result.is_err());
}
#[test]
fn test_is_pty_slave_self_stdin() {
let result = is_pty_slave("/proc/self/fd/0");
let _ = result;
}
#[test]
fn test_is_pty_slave_nonexistent() {
assert!(!is_pty_slave("/proc/999999999/fd/0"));
}
}