#![cfg(unix)]
#![allow(dead_code)]
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use tempfile::TempDir;
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
pub fn embedded_hook(file: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("assets/shell/lib")
.join(file)
}
pub fn tirith_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_tirith"))
}
pub fn tirith_bin_dir() -> PathBuf {
tirith_bin()
.parent()
.expect("CARGO_BIN_EXE_tirith must have a parent directory")
.to_path_buf()
}
pub fn modern_bash() -> Option<PathBuf> {
let mut candidates: Vec<PathBuf> = vec![
PathBuf::from("/opt/homebrew/bin/bash"),
PathBuf::from("/usr/local/bin/bash"),
];
if let Ok(out) = Command::new("sh").args(["-c", "command -v bash"]).output() {
if out.status.success() {
let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !p.is_empty() {
candidates.push(PathBuf::from(p));
}
}
}
candidates
.into_iter()
.find(|p| p.exists() && bash_major_version(p).map(|v| v >= 5).unwrap_or(false))
}
pub fn bash_major_version(path: &Path) -> Option<u32> {
let out = Command::new(path).arg("--version").output().ok()?;
if !out.status.success() {
return None;
}
let first = String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.unwrap_or_default()
.to_string();
let marker = "version ";
let idx = first.find(marker)?;
let rest = &first[idx + marker.len()..];
rest.split('.').next()?.trim().parse::<u32>().ok()
}
pub fn bash_version_string(path: &Path) -> Option<String> {
let out = Command::new(path)
.args(["-c", "printf '%s' \"$BASH_VERSION\""])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
if v.is_empty() {
None
} else {
Some(v)
}
}
pub fn fish_bin() -> Option<PathBuf> {
let out = Command::new("sh")
.args(["-c", "command -v fish"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
if p.is_empty() {
None
} else {
Some(PathBuf::from(p))
}
}
pub struct IsolatedEnv {
_root: TempDir,
pub home: PathBuf,
pub state_home: PathBuf,
pub data_home: PathBuf,
pub config_home: PathBuf,
pub workdir: PathBuf,
env: HashMap<String, String>,
}
impl IsolatedEnv {
pub fn new() -> Self {
let root = tempfile::tempdir().expect("pty harness: tempdir");
let base = root.path();
let home = base.join("home");
let state_home = base.join("state");
let data_home = base.join("data");
let config_home = base.join("config");
let workdir = base.join("work");
for d in [&home, &state_home, &data_home, &config_home, &workdir] {
std::fs::create_dir_all(d).expect("pty harness: create dir");
}
let mut env = HashMap::new();
env.insert("HOME".to_string(), home.display().to_string());
env.insert(
"XDG_STATE_HOME".to_string(),
state_home.display().to_string(),
);
env.insert("XDG_DATA_HOME".to_string(), data_home.display().to_string());
env.insert(
"XDG_CONFIG_HOME".to_string(),
config_home.display().to_string(),
);
env.insert("TERM".to_string(), "xterm-256color".to_string());
use std::sync::atomic::{AtomicU64, Ordering};
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
env.insert(
"TIRITH_SESSION_ID".to_string(),
format!(
"pty-conformance-{}-{}",
std::process::id(),
SESSION_COUNTER.fetch_add(1, Ordering::Relaxed)
),
);
env.insert("TIRITH_LOG".to_string(), "0".to_string());
Self {
_root: root,
home,
state_home,
data_home,
config_home,
workdir,
env,
}
}
pub fn set(&mut self, key: &str, val: &str) -> &mut Self {
self.env.insert(key.to_string(), val.to_string());
self
}
pub fn unset(&mut self, key: &str) -> &mut Self {
self.env.remove(key);
self
}
pub fn bash_safe_mode_flag(&self) -> PathBuf {
self.state_home.join("tirith").join("bash-safe-mode")
}
pub fn bash_enter_capability_file(&self) -> PathBuf {
self.state_home.join("tirith").join("bash-enter-capability")
}
pub fn seed_bash_enter_capability(&self, verdict: &str, bash_version: &str, bash_path: &Path) {
let path = self.bash_enter_capability_file();
std::fs::create_dir_all(path.parent().expect("capability cache parent"))
.expect("pty harness: create state dir");
let body = format!(
"schema=1\ntirith_version=\nshell=bash\nbash_version={bash_version}\n\
bash_path={}\nenter_capability={verdict}\n\
reason=seeded by pty conformance harness\n",
bash_path.display()
);
std::fs::write(&path, body).expect("pty harness: write capability cache");
}
}
impl Default for IsolatedEnv {
fn default() -> Self {
Self::new()
}
}
fn answer_terminal_queries(writer: &SharedWriter, data: &[u8]) {
let contains = |needle: &[u8]| -> bool {
!needle.is_empty() && data.windows(needle.len()).any(|w| w == needle)
};
let mut answer: Vec<u8> = Vec::new();
if contains(b"\x1b[0c") || contains(b"\x1b[c") || contains(b"\x1b[>0c") {
answer.extend_from_slice(b"\x1b[?1;2c");
}
if contains(b"\x1b]11;?") {
answer.extend_from_slice(b"\x1b]11;rgb:0000/0000/0000\x1b\\");
}
if contains(b"\x1b[6n") {
answer.extend_from_slice(b"\x1b[1;1R");
}
if contains(b"\x1b[?u") {
answer.extend_from_slice(b"\x1b[?0u");
}
if contains(b"\x1bP+q") {
answer.extend_from_slice(b"\x1bP0+r\x1b\\");
}
if !answer.is_empty() {
if let Ok(mut w) = writer.lock() {
let _ = w.write_all(&answer);
let _ = w.flush();
}
}
}
pub struct PtySession {
writer: SharedWriter,
child: Box<dyn portable_pty::Child + Send + Sync>,
rx: mpsc::Receiver<Vec<u8>>,
buf: String,
closed: bool,
}
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20);
impl PtySession {
pub fn spawn(env: &IsolatedEnv, program: &Path, args: &[&str]) -> Self {
let pair = native_pty_system()
.openpty(PtySize {
rows: 40,
cols: 100,
pixel_width: 0,
pixel_height: 0,
})
.expect("pty harness: openpty");
let mut cmd = CommandBuilder::new(program);
for a in args {
cmd.arg(a);
}
cmd.env_clear();
let parent_path = std::env::var("PATH").unwrap_or_default();
let path = if parent_path.is_empty() {
tirith_bin_dir().display().to_string()
} else {
format!("{}:{}", tirith_bin_dir().display(), parent_path)
};
cmd.env("PATH", path);
for (k, v) in &env.env {
cmd.env(k, v);
}
cmd.cwd(&env.workdir);
let child = pair
.slave
.spawn_command(cmd)
.expect("pty harness: spawn shell");
drop(pair.slave);
let writer: SharedWriter = Arc::new(Mutex::new(
pair.master.take_writer().expect("pty harness: take_writer"),
));
let mut reader = pair
.master
.try_clone_reader()
.expect("pty harness: clone_reader");
drop(pair.master);
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let answer_writer = Arc::clone(&writer);
thread::spawn(move || {
let mut chunk = [0u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
answer_terminal_queries(&answer_writer, &chunk[..n]);
if tx.send(chunk[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
Self {
writer,
child,
rx,
buf: String::new(),
closed: false,
}
}
fn pump(&mut self, slice: Duration) {
match self.rx.recv_timeout(slice) {
Ok(bytes) => self.buf.push_str(&String::from_utf8_lossy(&bytes)),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => self.closed = true,
}
while let Ok(bytes) = self.rx.try_recv() {
self.buf.push_str(&String::from_utf8_lossy(&bytes));
}
}
pub fn send_raw(&mut self, bytes: &[u8]) {
let mut w = self.writer.lock().unwrap_or_else(|e| e.into_inner());
w.write_all(bytes).expect("pty harness: write to pty");
w.flush().expect("pty harness: flush pty");
}
pub fn send_line(&mut self, line: &str) {
let mut s = line.to_string();
s.push('\r');
self.send_raw(s.as_bytes());
}
pub fn expect(&mut self, needle: &str) -> String {
self.expect_within(needle, DEFAULT_TIMEOUT)
}
pub fn expect_within(&mut self, needle: &str, timeout: Duration) -> String {
let deadline = Instant::now() + timeout;
loop {
if self.buf.contains(needle) {
return self.buf.clone();
}
if Instant::now() >= deadline {
panic!(
"pty harness: timed out after {:?} waiting for {:?}\n\
---- captured output ----\n{}\n-------------------------",
timeout,
needle,
self.buf.trim_end()
);
}
if self.closed && self.rx.try_recv().is_err() {
panic!(
"pty harness: shell exited before {:?} appeared\n\
---- captured output ----\n{}\n-------------------------",
needle,
self.buf.trim_end()
);
}
self.pump(Duration::from_millis(100));
}
}
pub fn expect_any(&mut self, needles: &[&str], timeout: Duration) -> String {
let deadline = Instant::now() + timeout;
loop {
if needles.iter().any(|n| self.buf.contains(n)) {
return self.buf.clone();
}
if Instant::now() >= deadline {
return self.buf.clone();
}
if self.closed && self.rx.try_recv().is_err() {
return self.buf.clone();
}
self.pump(Duration::from_millis(100));
}
}
pub fn appears_within(&mut self, needle: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if self.buf.contains(needle) {
return true;
}
if Instant::now() >= deadline {
return false;
}
self.pump(Duration::from_millis(100));
}
}
pub fn drain(&mut self, dur: Duration) -> String {
let deadline = Instant::now() + dur;
while Instant::now() < deadline {
self.pump(Duration::from_millis(100));
}
self.buf.clone()
}
pub fn output(&self) -> &str {
&self.buf
}
pub fn clear_buffer(&mut self) {
self.buf.clear();
}
pub fn wait_idle(&mut self, quiet: Duration, max: Duration) -> String {
let hard_deadline = Instant::now() + max;
loop {
let before = self.buf.len();
self.pump(quiet);
let settled = self.buf.len() == before;
if settled || Instant::now() >= hard_deadline {
return self.buf.clone();
}
}
}
pub fn close(&mut self) {
if self.closed {
return;
}
if let Ok(mut w) = self.writer.lock() {
let _ = w.write_all(b"exit\r");
let _ = w.flush();
}
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Ok(Some(_)) = self.child.try_wait() {
self.closed = true;
return;
}
thread::sleep(Duration::from_millis(50));
}
let _ = self.child.kill();
self.closed = true;
}
}
impl Drop for PtySession {
fn drop(&mut self) {
if !self.closed {
let _ = self.child.kill();
}
}
}
pub fn count_occurrences(haystack: &str, needle: &str) -> usize {
if needle.is_empty() {
return 0;
}
let mut count = 0;
let mut rest = haystack;
while let Some(idx) = rest.find(needle) {
count += 1;
rest = &rest[idx + needle.len()..];
}
count
}
pub fn wait_for_marker(marker: &Path, needle: &str, timeout: Duration) -> String {
let deadline = Instant::now() + timeout;
loop {
let body = std::fs::read_to_string(marker).unwrap_or_default();
if count_occurrences(&body, needle) >= 1 {
return body;
}
if Instant::now() >= deadline {
return body;
}
thread::sleep(Duration::from_millis(50));
}
}