diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f542d7..651220e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 1821f85..e0e6561 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -590,7 +590,7 @@ dependencies = [ [[package]] name = "prismtty" -version = "1.0.9" +version = "1.0.10" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index fac074b..ea4b809 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "prismtty" -version = "1.0.9" +version = "1.0.10" edition = "2024" rust-version = "1.85" license = "MIT" diff --git a/README.md b/README.md index f921706..58fd8c7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/docs/index.html b/docs/index.html index 3af7389..83c087f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -65,7 +65,7 @@

Readable output,
live in your
Latest
-
v1.0.9
+
v1.0.10
Engine
diff --git a/src/cli.rs b/src/cli.rs index 2226ee7..a339de3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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")] @@ -152,7 +152,11 @@ fn run_stdin(options: Options) -> Result { stdin.lock(), &mut stdout, &options, - interactive, + InputSource { + interactive, + pty_fd: None, + recent_input: None, + }, reload_watcher, trace, None, diff --git a/src/cli/pty.rs b/src/cli/pty.rs index 07abcae..4fc5a6f 100644 --- a/src/cli/pty.rs +++ b/src/cli/pty.rs @@ -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}, }; @@ -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] = [ @@ -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; @@ -154,15 +159,42 @@ pub(super) fn run_command(options: Options, command: Vec) -> Result) -> Result( local_echo: bool, trace: IoTrace, profile_input: Option>>, + recent_input: &Mutex>, + pty_fd: Option, ) -> io::Result<()> { let mut buffer = [0_u8; 1024]; let mut echo_state = LocalEchoState::default(); @@ -291,6 +329,9 @@ fn forward_stdin_to_pty( 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()?; @@ -305,6 +346,45 @@ fn forward_stdin_to_pty( } } +/// 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>, input: &[u8], pty_fd: Option) { + 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 { + 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 { LocalEchoState::default().push(input) @@ -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}; @@ -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] diff --git a/src/cli/stream.rs b/src/cli/stream.rs index 416a148..9c55802 100644 --- a/src/cli/stream.rs +++ b/src/cli/stream.rs @@ -1,9 +1,14 @@ use std::cmp::Reverse; use std::collections::HashMap; use std::io::{self, Read, Write}; +use std::os::fd::{BorrowedFd, RawFd}; use std::sync::mpsc; +use std::sync::{Arc, Mutex}; use std::time::Instant; +use nix::libc; +use nix::sys::termios::{LocalFlags, tcgetattr}; + use crate::highlight::{ AnsiChunk, BenchmarkReport, Highlighter, MAX_INCOMPLETE_ESCAPE_BYTES, StreamingHighlighter, incomplete_escape_start, strip_ansi, @@ -23,16 +28,28 @@ use super::trace::IoTrace; const AUTO_DETECT_SAMPLE_LIMIT: usize = 64 * 1024; -/// Largest read still treated as interactive keystroke echo. Bulk program output -/// arrives in much larger reads, so only tiny reads trigger an echo flush; that -/// keeps cross-read token highlighting intact for streamed output. -const INTERACTIVE_ECHO_FLUSH_MAX_READ: usize = 8; +/// Describes the stream's input source for interactive echo handling. It groups +/// the facts the read loop needs to surface buffered echo promptly without +/// disturbing program-output highlighting: +/// - `interactive`: whether this is an interactive terminal session (echo present), +/// - `pty_fd`: the PTY master to poll so an echo burst's buffered trailing token +/// is flushed once the child goes idle (a paste echoes back in one large read), +/// - `recent_input`: the bytes the stdin forwarder recently sent to the child. +/// The loop flushes a buffered trailing token only when it is a suffix of this +/// (i.e. genuine input echo), so a speculatively-buffered *program-output* +/// token is never surfaced standalone — even when the child has echo off and +/// the user is typing while output streams. +pub(super) struct InputSource { + pub(super) interactive: bool, + pub(super) pty_fd: Option, + pub(super) recent_input: Option>>>, +} pub(super) fn highlight_stream( mut reader: R, writer: &mut W, options: &Options, - interactive: bool, + input: InputSource, mut reload_watcher: Option, trace: IoTrace, profile_input_rx: Option>>, @@ -52,10 +69,10 @@ pub(super) fn highlight_stream( input_bytes += first_chunk.bytes().len(); let store = profile_store()?; let profile_names = select_profile_names_with_store(options, &store, &detection_sample)?; - let mut session = HighlightSession::new(options, &store, interactive, profile_names)?; + let mut session = HighlightSession::new(options, &store, input.interactive, profile_names)?; session.report_current(); let dynamic_profiles = - dynamic_profile_enabled(options, interactive) && profile_input_rx.is_some(); + dynamic_profile_enabled(options, input.interactive) && profile_input_rx.is_some(); let mut profile_runtime = if dynamic_profiles { Some(ProfileRuntime::new(session.profile_names().to_vec())) } else { @@ -72,7 +89,12 @@ pub(super) fn highlight_stream( session.switch_profiles(writer, &trace, next_profile_names)?; } session.push(writer, &trace, &first_chunk)?; - if should_flush_input_echo(interactive, read) { + if should_flush_input_echo( + input.interactive, + input.pty_fd, + session.buffered_echo(), + input.recent_input.as_deref(), + ) { session.flush_input_echo(writer, &trace)?; } writer.flush()?; @@ -112,7 +134,12 @@ pub(super) fn highlight_stream( session.reload(writer, &trace)?; } session.push(writer, &trace, &chunk)?; - if should_flush_input_echo(interactive, read) { + if should_flush_input_echo( + input.interactive, + input.pty_fd, + session.buffered_echo(), + input.recent_input.as_deref(), + ) { session.flush_input_echo(writer, &trace)?; } writer.flush()?; @@ -238,6 +265,12 @@ impl<'a> HighlightSession<'a> { Ok(()) } + /// The buffered trailing token the next echo flush would surface, for the + /// read loop to match against recent input before deciding to flush it. + fn buffered_echo(&self) -> &[u8] { + self.streaming.buffered_echo() + } + fn finish(&mut self, writer: &mut W, trace: &IoTrace) -> Result<(), CliError> { write_rendered(writer, trace, self.streaming.finish())?; Ok(()) @@ -368,12 +401,120 @@ fn prepare_chunk(input: &[u8], strip_existing_ansi: bool, strip_carry: &mut Vec< } } -fn should_flush_input_echo(interactive: bool, read: usize) -> bool { - interactive && read <= INTERACTIVE_ECHO_FLUSH_MAX_READ +/// Decides whether to surface the buffered interactive input echo now. +/// +/// prismtty speculatively buffers a trailing partial token so a token split +/// across reads still highlights as one unit. For interactive input echo that +/// buffering must not strand the last token: a keystroke or pasted line echoes +/// back and then the child goes idle, so the token would otherwise stay hidden +/// until the next byte. We flush it once four conditions hold: +/// - there is something buffered (`buffered_echo` non-empty), +/// - the echo burst has drained ([`input_source_idle`]), so we do not split a +/// multi-read echo mid-burst, and +/// - the child's terminal still has ECHO on, since echo only exists while ECHO +/// is on (so with it off a buffered token can only be program output), and +/// - the buffered token is a suffix of the recently forwarded input, i.e. it is +/// genuine echo of what the user typed/pasted — not a speculatively-buffered +/// token of bulk *program* output (which would lose its cross-read highlight +/// if flushed standalone). +/// +/// The ECHO check and the suffix match are complementary: the suffix match keeps +/// program output the user did not type from being flushed, and the ECHO check +/// covers the converse — a program-output token whose bytes happen to coincide +/// with non-echoing type-ahead (echo off) is still left buffered. +/// +/// Consumes the matched suffix from `recent_input` when it flushes. Also, when +/// the child's terminal ECHO is off, clears all of `recent_input` (without +/// flushing) — the forwarder is the primary guard there, this just drops any +/// stale tail the read loop observes during an ECHO-off stretch. +fn should_flush_input_echo( + interactive: bool, + pty_fd: Option, + buffered_echo: &[u8], + recent_input: Option<&Mutex>>, +) -> bool { + if !interactive || buffered_echo.is_empty() { + return false; + } + let Some(recent_input) = recent_input else { + return false; + }; + if !terminal_echo_enabled(pty_fd) { + recent_input + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + return false; + } + if !input_source_idle(pty_fd) { + return false; + } + let mut recent_input = recent_input + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + consume_echo_suffix(&mut recent_input, buffered_echo) +} + +/// Whether the child's terminal currently has ECHO enabled. Input is only +/// reflected back as echo while ECHO is on; with it off (password prompts, +/// raw-mode programs) a buffered token can only be program output, never input +/// echo — even if its bytes coincide with non-echoing type-ahead. The forwarder +/// uses the same check to avoid recording non-echoed input at all. Defaults to +/// `true` only when there is no descriptor; when a supplied descriptor cannot +/// report terminal state, fail closed. +pub(super) fn terminal_echo_enabled(pty_fd: Option) -> bool { + let Some(fd) = pty_fd else { + return true; + }; + // SAFETY: `fd` is an owned descriptor for the wrapped PTY (a dup of the + // master), kept open by its owner for the whole session; the borrow lasts + // only for this `tcgetattr` call. + let borrowed = unsafe { BorrowedFd::borrow_raw(fd) }; + match tcgetattr(borrowed) { + Ok(termios) => termios.local_flags.contains(LocalFlags::ECHO), + Err(_) => false, + } +} + +/// If `recent_input` ends with `echo`, the buffered token is genuine input echo: +/// consume that suffix (so it cannot be matched again by a later program token) +/// and return true. Otherwise the token is program output — leave `recent_input` +/// untouched and return false. +fn consume_echo_suffix(recent_input: &mut Vec, echo: &[u8]) -> bool { + if recent_input.ends_with(echo) { + recent_input.clear(); + true + } else { + false + } +} + +/// Reports whether the PTY master has no output readable at this instant — the +/// cue that an interactive echo burst has drained, so buffered echo can surface +/// without waiting for the next byte. A failed/interrupted poll is treated as +/// idle so echo still surfaces promptly; the ECHO-on check and the recent-input +/// suffix match in [`should_flush_input_echo`] keep that from disturbing +/// program-output buffering. Returns false when no descriptor is available +/// (stdin mode). +fn input_source_idle(pty_fd: Option) -> bool { + let Some(fd) = pty_fd else { + return false; + }; + let mut poll_fd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: `poll_fd` is one valid, initialized entry; `poll` only reads + // `fd`/`events` and writes `revents`. The zero timeout makes it nonblocking. + let ready = unsafe { libc::poll(&mut poll_fd, 1, 0) }; + ready <= 0 } #[cfg(test)] mod tests { + use std::sync::Mutex; + fn sample_highlighter() -> crate::highlight::Highlighter { let config = crate::config::PrismConfig::from_chromaterm_yaml("rules: []\n").expect("config loads"); @@ -464,19 +605,108 @@ mod tests { } #[test] - fn input_echo_flush_targets_only_small_interactive_reads() { - // Keystroke-sized interactive reads flush buffered echo promptly. - assert!(super::should_flush_input_echo(true, 1)); + fn consume_echo_suffix_matches_only_genuine_echo() { + // A buffered token that is a suffix of recent input is genuine echo: + // matched and cleared so retained input cannot linger past its use. + let mut recent = b"update add test.example.com 3600 A 192.0.2.1".to_vec(); + assert!(super::consume_echo_suffix(&mut recent, b"192.0.2.1")); + assert!( + recent.is_empty(), + "matched input is cleared after the echo tail is surfaced" + ); + // The same program-output bytes no longer match once consumed. + assert!(!super::consume_echo_suffix(&mut recent, b"192.0.2.1")); + + // A program-output token that was never typed does not match (this is the + // echo-off regression guard): recent input is the keystroke, not "Vlan11". + let mut typed = b"x".to_vec(); + assert!(!super::consume_echo_suffix(&mut typed, b"Vlan11")); + assert_eq!(typed, b"x", "non-matching input is left untouched"); + + // Empty recent input matches nothing. + let mut empty = Vec::new(); + assert!(!super::consume_echo_suffix(&mut empty, b"Vlan11")); + } + + #[test] + fn should_flush_input_echo_requires_interactive_buffered_and_recent_input() { + // No pty_fd: terminal_echo_enabled defaults true and input_source_idle + // returns false (stdin mode), so a non-interactive stream never flushes. + assert!(!super::should_flush_input_echo(false, None, b"tok", None)); + // Interactive but nothing buffered: never flushes. + let recent = Mutex::new(b"tok".to_vec()); + assert!(!super::should_flush_input_echo( + true, + None, + b"", + Some(&recent) + )); + // Interactive with buffered echo but no recent_input source: never flushes. + assert!(!super::should_flush_input_echo(true, None, b"tok", None)); + } + + #[test] + fn terminal_echo_enabled_fails_closed_for_non_terminal_fd() { + let mut fds = [0 as nix::libc::c_int; 2]; + assert_eq!(unsafe { nix::libc::pipe(fds.as_mut_ptr()) }, 0); + assert!(!super::terminal_echo_enabled(Some(fds[0]))); + + unsafe { + nix::libc::close(fds[0]); + nix::libc::close(fds[1]); + } + } + + #[test] + fn should_flush_input_echo_flushes_only_matching_idle_echo() { + 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 slave_fd = pty.slave.as_raw_fd(); + let mut attrs = tcgetattr(&pty.slave).expect("tcgetattr"); + attrs + .local_flags + .insert(nix::sys::termios::LocalFlags::ECHO); + tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo on"); + + // Buffered token is a suffix of recent input + idle: flush and clear it. + let recent = Mutex::new(b"router# show ".to_vec()); assert!(super::should_flush_input_echo( true, - super::INTERACTIVE_ECHO_FLUSH_MAX_READ + Some(master_fd), + b"show ", + Some(&recent), + )); + assert!( + recent.lock().unwrap().is_empty(), + "matched input is cleared after the echo tail is surfaced" + ); + + // Token is not recent input (program output): do not flush, leave it. + let recent = Mutex::new(b"x".to_vec()); + assert!(!super::should_flush_input_echo( + true, + Some(master_fd), + b"Vlan11", + Some(&recent), )); - // Bulk interactive reads keep speculative token buffering for highlighting. + assert_eq!(recent.lock().unwrap().as_slice(), b"x"); + + // Pending data == not idle: wait, do not consume. + assert_eq!( + unsafe { nix::libc::write(slave_fd, b"x".as_ptr().cast(), 1) }, + 1 + ); + let recent = Mutex::new(b"show ".to_vec()); assert!(!super::should_flush_input_echo( true, - super::INTERACTIVE_ECHO_FLUSH_MAX_READ + 1 + Some(master_fd), + b"show ", + Some(&recent), )); - // Noninteractive streams never force an early echo flush. - assert!(!super::should_flush_input_echo(false, 1)); + assert_eq!(recent.lock().unwrap().as_slice(), b"show "); } } diff --git a/src/highlight.rs b/src/highlight.rs index bee01a9..5f335af 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -598,8 +598,9 @@ impl StreamingHighlighter { /// Flushes buffered interactive input echo that is only waiting for a token /// boundary, while keeping an incomplete trailing escape sequence buffered. /// - /// Interactive callers should invoke this when no more input is immediately - /// available (for example after a keystroke-sized read) so typed characters + /// Interactive callers should invoke this when the input source has gone idle + /// and the buffered tail is genuine input echo (see + /// `cli::stream::should_flush_input_echo`), so typed or pasted characters /// surface promptly instead of staying buffered until the next byte completes /// a token. It is a no-op for noninteractive streams, where speculative /// token buffering is required for correct highlighting. @@ -624,6 +625,20 @@ impl StreamingHighlighter { output } + /// The raw bytes that [`Self::flush_buffered_echo`] would surface right now: + /// the buffered trailing token, minus any incomplete trailing escape. Empty + /// when nothing would flush. The read loop matches these against recently + /// forwarded input to confirm the tail is genuine echo before flushing it, + /// so a speculatively-buffered *program-output* token is never surfaced + /// standalone (which would split its highlight span). + pub(crate) fn buffered_echo(&self) -> &[u8] { + if !self.passthrough_single_byte_chunks || self.pending.is_empty() { + return &[]; + } + let flush_len = incomplete_escape_start(&self.pending).unwrap_or(self.pending.len()); + &self.pending[..flush_len] + } + fn highlight_output_chunk(&mut self, input: &AnsiChunk) -> Vec { if self.passthrough_single_byte_chunks { self.highlight_interactive_output_chunk(input) diff --git a/tests/interactive_paste.rs b/tests/interactive_paste.rs new file mode 100644 index 0000000..05a2b2d --- /dev/null +++ b/tests/interactive_paste.rs @@ -0,0 +1,342 @@ +//! Integration coverage for interactive input echo (the pasted-command bug). +//! +//! prismtty buffers a trailing partial token of interactive echo so a token +//! split across reads still highlights as a unit. A pasted line echoes back in +//! a single large read, so the buffered trailing token used to stay invisible +//! until the next keystroke surfaced it (reported against `nsupdate`, whose bare +//! `> ` prompt is not recognized, so echo is not passed through). prismtty must +//! instead surface it once the child goes idle. +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, +}; +use std::thread; +use std::time::{Duration, Instant}; + +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use prismtty::highlight::strip_ansi; + +/// A pasted command (no trailing newline) must become fully visible without any +/// further input. `cat` keeps the wrapped PTY in canonical echo mode, so the +/// tty line discipline echoes the paste exactly as `nsupdate`'s prompt would. +#[test] +fn pasted_line_is_fully_visible_without_extra_input() { + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + + let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty")); + builder.arg("sh"); + builder.arg("-c"); + builder.arg("printf 'READY\\n'; exec cat"); + + let mut child = pair.slave.spawn_command(builder).expect("spawn ptty cat"); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().expect("clone reader"); + let mut writer = pair.master.take_writer().expect("take writer"); + + // Stream echoed output from a thread so a blocking read on the buggy path + // (token never flushed) cannot hang the test; the main thread bounds the + // wait. Each read publishes the current visible (ANSI-stripped) text. + let (tx, rx) = mpsc::channel::(); + thread::spawn(move || { + let mut acc = Vec::new(); + let mut buf = [0u8; 256]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + acc.extend_from_slice(&buf[..n]); + let visible = String::from_utf8_lossy(&strip_ansi(&acc)).into_owned(); + if tx.send(visible).is_err() { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + // Wait until prismtty is forwarding child output before sending the paste. + let ready_deadline = Instant::now() + Duration::from_secs(5); + let mut visible = String::new(); + while Instant::now() < ready_deadline { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(latest) => { + visible = latest; + if visible.contains("READY") { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + assert!( + visible.contains("READY"), + "wrapped command was not ready before paste; saw: {visible:?}" + ); + + // A multi-word line whose final, delimiter-less token ("192.0.2.1") is the + // piece prismtty buffers. No trailing newline: the child stays at the line, + // exactly like a paste awaiting Enter. + let paste = b"update add test.example.com 3600 A 192.0.2.1"; + writer.write_all(paste).expect("write paste"); + writer.flush().expect("flush paste"); + + // Wait for the full line to surface. Crucially we send NO further bytes, so + // the trailing token can only appear via prismtty's idle flush. + let target = "update add test.example.com 3600 A 192.0.2.1"; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(latest) => { + visible = latest; + if visible.contains(target) { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + let _ = child.kill(); + let _ = child.wait(); + + assert!( + visible.contains(target), + "pasted line never fully surfaced without extra input; saw: {visible:?}" + ); +} + +fn count_subslice(haystack: &[u8], needle: &[u8]) -> usize { + if needle.is_empty() || haystack.len() < needle.len() { + return 0; + } + haystack + .windows(needle.len()) + .filter(|w| *w == needle) + .count() +} + +fn contains_sgr_span(haystack: &[u8], token: &[u8]) -> bool { + let mut rest = haystack; + while let Some(esc_idx) = rest.iter().position(|byte| *byte == 0x1b) { + let candidate = &rest[esc_idx..]; + let Some(m_idx) = candidate.iter().position(|byte| *byte == b'm') else { + return false; + }; + if candidate[m_idx + 1..].starts_with(token) { + return true; + } + rest = &candidate[1..]; + } + false +} + +/// The mirror invariant: a token split across reads in pure PROGRAM output (no +/// input echo) must keep its cross-read highlighting. The idle flush surfaces +/// input echo, so it must NOT fire for buffered program-output tokens — there is +/// no pending input echo. Regression guard for the bulk-output highlighting that +/// an unconditional idle flush would break (cisco "Vlan1191" split as +/// "...Vlan11" + "91" across two reads with an inter-write gap). +#[test] +fn split_program_output_token_keeps_single_highlight_span() { + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + + let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty")); + builder.arg("-p"); + builder.arg("cisco"); + builder.arg("sh"); + builder.arg("-c"); + // First write is >8 bytes and ends mid-token; the gap lets prismtty read it + // (and go idle) before the rest arrives, so the token genuinely spans reads. + builder + .arg("printf 'show: Vlan11'; sleep 0.25; printf '91 New TZ GW to Internal\\n'; sleep 0.3"); + + let mut child = pair.slave.spawn_command(builder).expect("spawn ptty"); + drop(pair.slave); + + // Pure program output: we never write to the master, so no input echo is + // pending and the idle flush must leave the buffered token alone. + let mut reader = pair.master.try_clone_reader().expect("clone reader"); + let (tx, rx) = mpsc::channel::>(); + thread::spawn(move || { + let mut acc = Vec::new(); + let mut buf = [0u8; 256]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + acc.extend_from_slice(&buf[..n]); + if tx.send(acc.clone()).is_err() { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut out = Vec::new(); + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(latest) => { + out = latest; + if contains_sgr_span(&out, b"Vlan1191") { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + let _ = child.kill(); + let _ = child.wait(); + + assert!( + contains_sgr_span(&out, b"Vlan1191"), + "split program-output token lost its single highlight span; saw: {:?}", + String::from_utf8_lossy(&out) + ); +} + +/// Runs an echo-off child that streams the cisco token "Vlan1191" split across +/// two writes per iteration, while a thread types `typed` bytes throughout, and +/// returns the captured output. With echo off the typed bytes never echo back, +/// so the buffered token is always program output and its span must stay intact +/// regardless of what is typed. +fn echo_off_split_stream_while_typing(typed: &'static [u8]) -> Vec { + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + + let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty")); + builder.arg("-p"); + builder.arg("cisco"); + builder.arg("sh"); + builder.arg("-c"); + builder.arg( + "stty -echo; i=0; while [ $i -lt 12 ]; do printf 'aaaaaaaa Vlan11'; \ + sleep 0.12; printf '91 bbbb\\n'; sleep 0.12; i=$((i+1)); done", + ); + + let mut child = pair.slave.spawn_command(builder).expect("spawn ptty"); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().expect("clone reader"); + let mut writer = pair.master.take_writer().expect("take writer"); + + // Type throughout, then stop explicitly. Linux PTYs can keep accepting + // master writes briefly after the child exits, so do not rely on write + // failure as the only thread-exit signal. + let stop_typing = Arc::new(AtomicBool::new(false)); + let typer_stop = Arc::clone(&stop_typing); + let typer = thread::spawn(move || { + while !typer_stop.load(Ordering::Relaxed) { + if writer.write_all(typed).is_err() || writer.flush().is_err() { + break; + } + thread::sleep(Duration::from_millis(30)); + } + }); + + let (tx, rx) = mpsc::channel::>(); + thread::spawn(move || { + let mut acc = Vec::new(); + let mut buf = [0u8; 256]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + acc.extend_from_slice(&buf[..n]); + if tx.send(acc.clone()).is_err() { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + let deadline = Instant::now() + Duration::from_secs(8); + let mut out = Vec::new(); + loop { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(latest) => out = latest, + Err(mpsc::RecvTimeoutError::Timeout) => { + if Instant::now() >= deadline { + break; + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + let _ = child.kill(); + let _ = child.wait(); + stop_typing.store(true, Ordering::Relaxed); + let _ = typer.join(); + out +} + +fn assert_spans_intact(out: &[u8]) { + let broken = count_subslice(out, b"mVlan11\x1b[39m91"); + assert_eq!( + broken, + 0, + "concurrent input split {broken} program-output token span(s); saw: {:?}", + String::from_utf8_lossy(out) + ); + assert!( + contains_sgr_span(out, b"Vlan1191"), + "expected at least one intact Vlan1191 span; saw: {:?}", + String::from_utf8_lossy(out) + ); +} + +/// Non-matching type-ahead must not split a streamed program token. A coarse +/// "input happened" signal would wrongly flush the buffered token; the suffix +/// match leaves it buffered because "x" is not the token. +#[test] +fn split_program_output_token_survives_concurrent_nonechoing_input() { + assert_spans_intact(&echo_off_split_stream_while_typing(b"x")); +} + +/// The adversarial case the suffix match alone cannot catch: with echo off the +/// user types the EXACT bytes of the streamed token. Content matching would see +/// recent input ending in those bytes and flush the *program* token, splitting +/// it. The ECHO-state gate closes this: with echo off there is no input echo at +/// all, so nothing is surfaced and the span stays intact. +#[test] +fn split_program_output_token_survives_concurrent_input_matching_the_token() { + assert_spans_intact(&echo_off_split_stream_while_typing(b"Vlan11")); +}