Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

All notable changes to PrismTTY are documented here.

## 1.0.10 - 2026-06-05

### Interactive Rendering

- Surface pasted interactive input echo once the wrapped child PTY goes idle,
so a delimiter-less trailing token does not stay hidden until the next
keystroke.
- Preserve cross-read highlighting for program output while type-ahead is
present, including no-echo terminal modes such as password prompts.

### Security and Reliability

- Fail closed when an interactive PTY descriptor cannot report terminal ECHO
state, and make the PTY descriptor duplication used for echo checks explicit
and fallible.
- Clear recently matched echoed input after it has served the echo-tail match,
reducing how long typed or pasted command text remains in memory.

### Tests

- Add integration coverage for pasted input visibility, split program-output
highlighting, and concurrent no-echo input edge cases.

## 1.0.9 - 2026-05-30

### Security and Reliability
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "prismtty"
version = "1.0.9"
version = "1.0.10"
edition = "2024"
rust-version = "1.85"
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ network-focused built-in profiles.

Website: [prismtty.com](https://prismtty.com/).

Current version: `1.0.9`.
Current version: `1.0.10`.

## Quick Demo

Expand Down Expand Up @@ -99,7 +99,7 @@ sudo apt-get install libpcre2-dev pkg-config
### GitHub Release

Prebuilt release archives and checksums are available on the
[v1.0.9 release page](https://github.com/inxbit/prismtty/releases/tag/v1.0.9).
[v1.0.10 release page](https://github.com/inxbit/prismtty/releases/tag/v1.0.10).

Each release archive contains the binaries, license/readme files, example
profiles, shell completions, and a `.tar.gz.sha256` checksum.
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ <h1 id="hero-title">Readable output,<br><span class="spectrum-text">live in your
</div>
<div>
<dt>Latest</dt>
<dd>v1.0.9</dd>
<dd>v1.0.10</dd>
</div>
<div>
<dt>Engine</dt>
Expand Down
8 changes: 6 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use args::{Action, Options, parse_args, print_help};
use profile_selection::profile_store;
use pty::run_command;
use runtime::{ReloadWatcher, RuntimeRegistration, request_reload};
use stream::highlight_stream;
use stream::{InputSource, highlight_stream};
use trace::IoTrace;

#[cfg(feature = "completion-generation")]
Expand Down Expand Up @@ -152,7 +152,11 @@ fn run_stdin(options: Options) -> Result<ExitCode, CliError> {
stdin.lock(),
&mut stdout,
&options,
interactive,
InputSource {
interactive,
pty_fd: None,
recent_input: None,
},
reload_watcher,
trace,
None,
Expand Down
165 changes: 157 additions & 8 deletions src/cli/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ use std::ffi::{OsStr, OsString};
use std::fs::OpenOptions;
use std::io::{self, Read, Write};
use std::mem;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd};
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd};
use std::process::ExitCode;
use std::sync::{
Arc, Mutex,
atomic::{AtomicI32, AtomicPtr, Ordering},
mpsc::{self, SyncSender},
};
Expand All @@ -21,7 +22,7 @@ use super::CliError;
use super::args::Options;
use super::profile_selection::dynamic_profile_enabled;
use super::runtime::{ReloadWatcher, RuntimeRegistration};
use super::stream::highlight_stream;
use super::stream::{InputSource, highlight_stream, terminal_echo_enabled};
use super::trace::IoTrace;

const STRIPPED_ITERM_ENV: [&str; 6] = [
Expand All @@ -33,6 +34,10 @@ const STRIPPED_ITERM_ENV: [&str; 6] = [
"ITERM_PROFILE",
];
const PROFILE_INPUT_QUEUE_CAPACITY: usize = 1024;
/// How many bytes of recently forwarded input the highlight loop keeps to match
/// against buffered echo. A buffered token is small (≤512 bytes), so this only
/// needs to cover the tail of the latest input; older input is dropped.
const RECENT_INPUT_WINDOW: usize = 4096;
const TERMINATING_SIGNALS: [libc::c_int; 4] =
[libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT, libc::SIGINT];
const RAW_SIGNAL_STOP_BYTE: u8 = 0;
Expand Down Expand Up @@ -154,15 +159,42 @@ pub(super) fn run_command(options: Options, command: Vec<OsString>) -> Result<Ex
(None, None)
};

// An owned dup of the wrapped PTY master fd, kept for the whole session. The
// read loop polls it (idle detection) and reads its ECHO flag, and the stdin
// forwarder reads its ECHO flag too. Duping keeps these uses independent of
// the resize watcher (which owns the master itself), so a resize-thread exit
// cannot close the descriptor out from under the read loop.
let pty_fd_owned = if interactive {
Some(dup_master_fd(&*pair.master)?)
} else {
None
};
let pty_fd = pty_fd_owned.as_ref().map(AsRawFd::as_raw_fd);

// Records bytes forwarded to the child so the highlight loop can recognise
// their echo: it surfaces a buffered trailing token on idle only when the
// token is a suffix of this (genuine echo), never a speculatively-buffered
// token of program output. Input forwarded while the child's terminal ECHO
// is off (e.g. a password) is never recorded — it cannot echo, so retaining
// it would only risk a stale match and keep a secret in memory.
let recent_input = Arc::new(Mutex::new(Vec::new()));
if raw_mode.is_some() {
let mut writer = pair.master.take_writer()?;
let trace = trace.clone();
let local_echo = options.local_echo;
let recent_input = Arc::clone(&recent_input);
spawn_supervised("input forwarding", true, move || {
let stdin = io::stdin();
let mut stdin = stdin.lock();
let _ =
forward_stdin_to_pty(&mut stdin, &mut writer, local_echo, trace, profile_input_tx);
let _ = forward_stdin_to_pty(
&mut stdin,
&mut writer,
local_echo,
trace,
profile_input_tx,
&recent_input,
pty_fd,
);
});
}

Expand All @@ -176,7 +208,11 @@ pub(super) fn run_command(options: Options, command: Vec<OsString>) -> Result<Ex
&mut reader,
&mut stdout,
&options,
interactive,
InputSource {
interactive,
pty_fd,
recent_input: Some(recent_input),
},
reload_watcher,
trace,
profile_input_rx,
Expand Down Expand Up @@ -278,6 +314,8 @@ fn forward_stdin_to_pty<R: Read, W: Write>(
local_echo: bool,
trace: IoTrace,
profile_input: Option<SyncSender<Vec<u8>>>,
recent_input: &Mutex<Vec<u8>>,
pty_fd: Option<RawFd>,
) -> io::Result<()> {
let mut buffer = [0_u8; 1024];
let mut echo_state = LocalEchoState::default();
Expand All @@ -291,6 +329,9 @@ fn forward_stdin_to_pty<R: Read, W: Write>(
if let Some(sender) = &profile_input {
let _ = sender.try_send(input.to_vec());
}
// Record before writing to the PTY so an immediate line-discipline echo
// cannot outrun the highlight loop's suffix match.
record_recent_input(recent_input, input, pty_fd);
writer.write_all(input)?;
writer.flush()?;

Expand All @@ -305,6 +346,45 @@ fn forward_stdin_to_pty<R: Read, W: Write>(
}
}

/// Records forwarded input for the highlight loop's echo matching.
///
/// While the child's terminal has ECHO on, appends `input`, retaining only the
/// last [`RECENT_INPUT_WINDOW`] bytes so the buffer cannot grow without bound.
/// While ECHO is off (password prompts, raw-mode programs) the input never
/// echoes back, so it is not recorded; the buffer is cleared instead, so a typed
/// secret is not retained and cannot later be mistaken for program output.
fn record_recent_input(recent_input: &Mutex<Vec<u8>>, input: &[u8], pty_fd: Option<RawFd>) {
let mut recent = recent_input
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !terminal_echo_enabled(pty_fd) {
recent.clear();
return;
}
recent.extend_from_slice(input);
let overflow = recent.len().saturating_sub(RECENT_INPUT_WINDOW);
if overflow > 0 {
recent.drain(..overflow);
}
}

fn dup_master_fd(master: &dyn portable_pty::MasterPty) -> io::Result<OwnedFd> {
let fd = master.as_raw_fd().ok_or_else(|| {
io::Error::new(
io::ErrorKind::Unsupported,
"PTY master file descriptor is unavailable",
)
})?;
// SAFETY: `fd` is the live PTY master descriptor. `dup` returns a fresh
// descriptor that `OwnedFd` takes sole ownership of and closes on drop.
let duped = unsafe { libc::dup(fd) };
if duped < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `duped` is a newly-created descriptor owned by this function.
Ok(unsafe { OwnedFd::from_raw_fd(duped) })
}

#[cfg(test)]
fn local_echo_bytes(input: &[u8]) -> Vec<u8> {
LocalEchoState::default().push(input)
Expand Down Expand Up @@ -737,6 +817,7 @@ fn restore_signal_handler(signal: libc::c_int, previous: &libc::sigaction) {
#[cfg(test)]
mod tests {
use std::io::Cursor;
use std::sync::Mutex;

use nix::sys::termios::{InputFlags, LocalFlags, OutputFlags};

Expand Down Expand Up @@ -827,11 +908,79 @@ mod tests {
let mut input = Cursor::new(b"show version\n".to_vec());
let mut output = Vec::new();
let trace = super::IoTrace::open(None).expect("trace disabled");

super::forward_stdin_to_pty(&mut input, &mut output, false, trace, Some(tx))
.expect("stdin forwards even when profile input queue is full");
let recent_input = Mutex::new(Vec::new());

// pty_fd: None makes terminal_echo_enabled default to true, so input is
// recorded (the ECHO-off skip path is covered by the integration tests).
super::forward_stdin_to_pty(
&mut input,
&mut output,
false,
trace,
Some(tx),
&recent_input,
None,
)
.expect("stdin forwards even when profile input queue is full");

assert_eq!(output, b"show version\n");
assert_eq!(
recent_input.lock().unwrap().as_slice(),
b"show version\n",
"forwarded input is recorded for echo matching by the highlight loop"
);
}

#[test]
fn forwarded_input_is_recorded_before_child_can_echo_it() {
let source = include_str!("pty.rs");
let function_source = source
.split("fn forward_stdin_to_pty")
.nth(1)
.expect("forwarder exists")
.split("#[cfg(test)]")
.next()
.expect("function precedes tests");

let record_idx = function_source
.find("record_recent_input(recent_input, input,")
.expect("recent input is recorded");
let write_idx = function_source
.find("writer.write_all(input)?")
.expect("input is written to child PTY");

assert!(
record_idx < write_idx,
"recent input must be recorded before PTY write so immediate echo can be matched"
);
}

#[test]
fn record_recent_input_skips_non_echoed_input() {
use nix::pty::openpty;
use nix::sys::termios::{SetArg, tcgetattr, tcsetattr};
use std::os::fd::AsRawFd;

let pty = openpty(None, None).expect("openpty");
let master_fd = pty.master.as_raw_fd();
let recent = Mutex::new(Vec::new());

// ECHO on: forwarded input is recorded for echo matching.
let mut attrs = tcgetattr(&pty.slave).expect("tcgetattr");
attrs.local_flags.insert(LocalFlags::ECHO);
tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo on");
super::record_recent_input(&recent, b"show version", Some(master_fd));
assert_eq!(recent.lock().unwrap().as_slice(), b"show version");

// ECHO off (e.g. a password prompt): the typed secret is never recorded,
// and any prior tail is cleared, so it cannot be retained or later matched.
attrs.local_flags.remove(LocalFlags::ECHO);
tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo off");
super::record_recent_input(&recent, b"hunter2-secret-passphrase", Some(master_fd));
assert!(
recent.lock().unwrap().is_empty(),
"non-echoed input (password) must not be retained in recent_input"
);
}

#[test]
Expand Down
Loading
Loading