Preserve native terminal behavior through trusted prompts
This commit is contained in:
parent
7e68cfdc1f
commit
f989f3bc01
7 changed files with 997 additions and 151 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1906,6 +1906,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"vt100",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ sha2.workspace = true
|
||||||
nix.workspace = true
|
nix.workspace = true
|
||||||
ratatui.workspace = true
|
ratatui.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
vt100.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "=3.27.0"
|
tempfile = "=3.27.0"
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ use rustix::termios::{
|
||||||
|
|
||||||
use crate::{ClientError, ClientSessionIdentity};
|
use crate::{ClientError, ClientSessionIdentity};
|
||||||
|
|
||||||
|
mod presentation;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
|
|
||||||
|
use presentation::PresentationTracker;
|
||||||
use prompt::{
|
use prompt::{
|
||||||
PromptChoice, PromptInput, PromptInputDecoder, PromptLayout, PromptLineKind, PromptView,
|
PromptChoice, PromptInput, PromptInputDecoder, PromptLayout, PromptLineKind, PromptView,
|
||||||
prompt_outcome,
|
prompt_outcome,
|
||||||
|
|
@ -85,6 +87,17 @@ pub trait HostTerminal {
|
||||||
/// Returns an error while writing merged terminal output.
|
/// Returns an error while writing merged terminal output.
|
||||||
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()>;
|
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()>;
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
/// Returns an error while updating the physical presentation for a resize.
|
||||||
|
fn resize_presentation(&mut self, _resize: &TerminalResize) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn trusted_presentation_active(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn take_deferred_resize(&mut self) -> Option<TerminalResize> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
/// # Errors
|
||||||
/// Returns an error while collecting one explicit developer decision.
|
/// Returns an error while collecting one explicit developer decision.
|
||||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome>;
|
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome>;
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
@ -110,8 +123,31 @@ pub struct SystemTerminal {
|
||||||
line_start: bool,
|
line_start: bool,
|
||||||
escape_pending: bool,
|
escape_pending: bool,
|
||||||
key_translator: KeyTranslator,
|
key_translator: KeyTranslator,
|
||||||
|
presentation: PresentationTracker,
|
||||||
|
trusted: Option<TrustedPresentation>,
|
||||||
|
physical_screen: PhysicalScreen,
|
||||||
|
deferred_resize: Option<TerminalResize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
|
enum PhysicalScreen {
|
||||||
|
Primary,
|
||||||
|
Alternate,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrustedPresentation {
|
||||||
|
prompt: TerminalPrompt,
|
||||||
|
choice: PromptChoice,
|
||||||
|
view: PromptView,
|
||||||
|
guest_alternate: bool,
|
||||||
|
decision_complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT_INPUT_MODES_OFF: &[u8] =
|
||||||
|
b"\x1b>\x1b[?1l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?2004l";
|
||||||
|
const HOST_TERMINAL_MODES: &[u8] =
|
||||||
|
b"\x1b[?2026l\x1b>\x1b[?1l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?2004l\x1b[?7h\x1b[?25h";
|
||||||
|
|
||||||
struct KeyTranslator {
|
struct KeyTranslator {
|
||||||
mappings: &'static [(&'static [u8], &'static [u8])],
|
mappings: &'static [(&'static [u8], &'static [u8])],
|
||||||
pending: Vec<u8>,
|
pending: Vec<u8>,
|
||||||
|
|
@ -210,6 +246,7 @@ impl SystemTerminal {
|
||||||
/// Returns an error when the process has no usable controlling terminal.
|
/// Returns an error when the process has no usable controlling terminal.
|
||||||
pub fn open() -> io::Result<Self> {
|
pub fn open() -> io::Result<Self> {
|
||||||
let terminal = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
|
let terminal = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
|
||||||
|
let size = tcgetwinsize(&terminal)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
original: tcgetattr(&terminal)?,
|
original: tcgetattr(&terminal)?,
|
||||||
original_flags: fcntl_getfl(&terminal)?,
|
original_flags: fcntl_getfl(&terminal)?,
|
||||||
|
|
@ -220,6 +257,10 @@ impl SystemTerminal {
|
||||||
line_start: true,
|
line_start: true,
|
||||||
escape_pending: false,
|
escape_pending: false,
|
||||||
key_translator: KeyTranslator::for_profile(std::env::var("TERM").ok().as_deref()),
|
key_translator: KeyTranslator::for_profile(std::env::var("TERM").ok().as_deref()),
|
||||||
|
presentation: PresentationTracker::new(size.ws_row.max(1), size.ws_col.max(1)),
|
||||||
|
trusted: None,
|
||||||
|
physical_screen: PhysicalScreen::Primary,
|
||||||
|
deferred_resize: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,8 +336,9 @@ impl HostTerminal for SystemTerminal {
|
||||||
if !self.raw {
|
if !self.raw {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.terminal.write_all(b"\x1b[?7h\x1b[?25h")?;
|
self.leave_for_host()?;
|
||||||
self.terminal.flush()?;
|
self.trusted = None;
|
||||||
|
self.deferred_resize = None;
|
||||||
fcntl_setfl(&self.terminal, self.original_flags)?;
|
fcntl_setfl(&self.terminal, self.original_flags)?;
|
||||||
tcsetattr(&self.terminal, OptionalActions::Flush, &self.original)?;
|
tcsetattr(&self.terminal, OptionalActions::Flush, &self.original)?;
|
||||||
self.raw = false;
|
self.raw = false;
|
||||||
|
|
@ -347,18 +389,61 @@ impl HostTerminal for SystemTerminal {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()> {
|
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
if self.trusted.is_some() {
|
||||||
|
self.restore_guest_presentation()?;
|
||||||
|
}
|
||||||
|
self.presentation.process(bytes);
|
||||||
self.terminal.write_all(bytes)?;
|
self.terminal.write_all(bytes)?;
|
||||||
self.terminal.flush()
|
self.terminal.flush()?;
|
||||||
|
self.physical_screen = if self.presentation.alternate_screen() {
|
||||||
|
PhysicalScreen::Alternate
|
||||||
|
} else {
|
||||||
|
PhysicalScreen::Primary
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_presentation(&mut self, resize: &TerminalResize) -> io::Result<()> {
|
||||||
|
self.presentation
|
||||||
|
.resize(resize.rows.get(), resize.columns.get());
|
||||||
|
if self.trusted.is_some() {
|
||||||
|
self.render_trusted_prompt(true)?;
|
||||||
|
if self
|
||||||
|
.trusted
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|trusted| trusted.decision_complete)
|
||||||
|
{
|
||||||
|
self.deferred_resize = Some(*resize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trusted_presentation_active(&self) -> bool {
|
||||||
|
self.trusted.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_deferred_resize(&mut self) -> Option<TerminalResize> {
|
||||||
|
self.deferred_resize.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
|
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
|
||||||
|
if self.trusted.is_some() {
|
||||||
|
return Err(io::Error::other("terminal prompt is already active"));
|
||||||
|
}
|
||||||
self.events.clear();
|
self.events.clear();
|
||||||
self.escape_pending = false;
|
self.escape_pending = false;
|
||||||
self.key_translator.clear();
|
self.key_translator.clear();
|
||||||
tcflush(&self.terminal, QueueSelector::IFlush)?;
|
tcflush(&self.terminal, QueueSelector::IFlush)?;
|
||||||
let mut decoder = PromptInputDecoder::new();
|
let mut decoder = PromptInputDecoder::new();
|
||||||
let mut view = PromptView::default();
|
self.trusted = Some(TrustedPresentation {
|
||||||
self.render_prompt(prompt, decoder.choice, &mut view, false)?;
|
prompt: prompt.clone(),
|
||||||
|
choice: decoder.choice,
|
||||||
|
view: PromptView::default(),
|
||||||
|
guest_alternate: self.presentation.alternate_screen(),
|
||||||
|
decision_complete: false,
|
||||||
|
});
|
||||||
|
self.render_trusted_prompt(false)?;
|
||||||
let mut resumed_size = None;
|
let mut resumed_size = None;
|
||||||
loop {
|
loop {
|
||||||
match self.next_event()? {
|
match self.next_event()? {
|
||||||
|
|
@ -367,32 +452,41 @@ impl HostTerminal for SystemTerminal {
|
||||||
let input = decoder.feed(&bytes, Instant::now());
|
let input = decoder.feed(&bytes, Instant::now());
|
||||||
let mut reviewed = false;
|
let mut reviewed = false;
|
||||||
while let Some(review) = decoder.take_review() {
|
while let Some(review) = decoder.take_review() {
|
||||||
view.apply_review(review);
|
self.trusted
|
||||||
|
.as_mut()
|
||||||
|
.expect("active prompt has trusted presentation")
|
||||||
|
.view
|
||||||
|
.apply_review(review);
|
||||||
reviewed = true;
|
reviewed = true;
|
||||||
}
|
}
|
||||||
if let PromptInput::Decide(choice) = input {
|
if let PromptInput::Decide(choice) = input {
|
||||||
return self.complete_prompt(prompt_outcome(choice, resumed_size));
|
return Ok(self.complete_prompt_decision(choice, resumed_size));
|
||||||
}
|
}
|
||||||
if decoder.choice != previous || reviewed {
|
if decoder.choice != previous || reviewed {
|
||||||
self.render_prompt(prompt, decoder.choice, &mut view, false)?;
|
self.trusted
|
||||||
|
.as_mut()
|
||||||
|
.expect("active prompt has trusted presentation")
|
||||||
|
.choice = decoder.choice;
|
||||||
|
self.render_trusted_prompt(false)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HostTerminalEvent::LocalSuspend => {
|
HostTerminalEvent::LocalSuspend => {
|
||||||
self.suspend_and_resume()?;
|
self.suspend_and_resume()?;
|
||||||
resumed_size = Some(self.size(prompt.session_id)?);
|
let resize = self.size(prompt.session_id)?;
|
||||||
self.render_prompt(prompt, decoder.choice, &mut view, true)?;
|
self.resize_presentation(&resize)?;
|
||||||
|
resumed_size = Some(resize);
|
||||||
}
|
}
|
||||||
HostTerminalEvent::Terminate => {
|
HostTerminalEvent::Terminate => {
|
||||||
self.write_output(b"\x1b[?7h\x1b[?25h")?;
|
|
||||||
return Err(io::ErrorKind::Interrupted.into());
|
return Err(io::ErrorKind::Interrupted.into());
|
||||||
}
|
}
|
||||||
HostTerminalEvent::Resize => {
|
HostTerminalEvent::Resize => {
|
||||||
resumed_size = Some(self.size(prompt.session_id)?);
|
let resize = self.size(prompt.session_id)?;
|
||||||
self.render_prompt(prompt, decoder.choice, &mut view, true)?;
|
self.resize_presentation(&resize)?;
|
||||||
|
resumed_size = Some(resize);
|
||||||
}
|
}
|
||||||
HostTerminalEvent::Idle => {
|
HostTerminalEvent::Idle => {
|
||||||
if let PromptInput::Decide(choice) = decoder.expire(Instant::now()) {
|
if let PromptInput::Decide(choice) = decoder.expire(Instant::now()) {
|
||||||
return self.complete_prompt(prompt_outcome(choice, resumed_size));
|
return Ok(self.complete_prompt_decision(choice, resumed_size));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HostTerminalEvent::Break => {}
|
HostTerminalEvent::Break => {}
|
||||||
|
|
@ -401,33 +495,110 @@ impl HostTerminal for SystemTerminal {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn suspend_and_resume(&mut self) -> io::Result<()> {
|
fn suspend_and_resume(&mut self) -> io::Result<()> {
|
||||||
self.restore()?;
|
self.leave_for_host()?;
|
||||||
|
fcntl_setfl(&self.terminal, self.original_flags)?;
|
||||||
|
tcsetattr(&self.terminal, OptionalActions::Flush, &self.original)?;
|
||||||
|
self.raw = false;
|
||||||
lazaret_linux::process::suspend_current_process()?;
|
lazaret_linux::process::suspend_current_process()?;
|
||||||
self.enter_raw()
|
self.enter_raw()?;
|
||||||
|
self.reconstruct_after_resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemTerminal {
|
impl SystemTerminal {
|
||||||
fn complete_prompt(&mut self, outcome: PromptOutcome) -> io::Result<PromptOutcome> {
|
fn complete_prompt_decision(
|
||||||
self.write_output(b"\x1b[?7h\x1b[?25h")?;
|
&mut self,
|
||||||
Ok(outcome)
|
choice: PromptChoice,
|
||||||
|
resumed_size: Option<TerminalResize>,
|
||||||
|
) -> PromptOutcome {
|
||||||
|
self.trusted
|
||||||
|
.as_mut()
|
||||||
|
.expect("prompt decision requires an active trusted presentation")
|
||||||
|
.decision_complete = true;
|
||||||
|
prompt_outcome(choice, resumed_size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_prompt(
|
fn leave_for_host(&mut self) -> io::Result<()> {
|
||||||
&mut self,
|
self.terminal.write_all(HOST_TERMINAL_MODES)?;
|
||||||
prompt: &TerminalPrompt,
|
if self.physical_screen == PhysicalScreen::Alternate {
|
||||||
choice: PromptChoice,
|
self.terminal.write_all(b"\x1b[?1049l")?;
|
||||||
view: &mut PromptView,
|
self.physical_screen = PhysicalScreen::Primary;
|
||||||
clear_before: bool,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let size = self.size(prompt.session_id)?;
|
|
||||||
let layout = PromptLayout::new(prompt, size.columns.get(), size.rows.get());
|
|
||||||
self.terminal.write_all(b"\x1b[?2026h\x1b[?25l\x1b[?7l")?;
|
|
||||||
if clear_before {
|
|
||||||
self.terminal.write_all(b"\x1b[2J\x1b[H")?;
|
|
||||||
}
|
}
|
||||||
render_prompt_card(&mut self.terminal, &layout, choice, view)?;
|
self.terminal.flush()
|
||||||
self.terminal.write_all(b"\x1b[?7h\x1b[?2026l")?;
|
}
|
||||||
|
|
||||||
|
fn reconstruct_after_resume(&mut self) -> io::Result<()> {
|
||||||
|
if self.trusted.is_some() {
|
||||||
|
return self.render_trusted_prompt(true);
|
||||||
|
}
|
||||||
|
self.terminal.write_all(b"\x1b[?2026l\x1b[?2026h")?;
|
||||||
|
if self.presentation.alternate_screen() {
|
||||||
|
self.terminal.write_all(b"\x1b[?1049h")?;
|
||||||
|
self.physical_screen = PhysicalScreen::Alternate;
|
||||||
|
}
|
||||||
|
self.terminal
|
||||||
|
.write_all(&self.presentation.restoration_snapshot())?;
|
||||||
|
self.terminal.write_all(b"\x1b[?2026l")?;
|
||||||
|
self.terminal
|
||||||
|
.write_all(&self.presentation.synchronized_output_mode())?;
|
||||||
|
self.terminal.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_trusted_prompt(&mut self, reconstruct: bool) -> io::Result<()> {
|
||||||
|
let mut trusted = self
|
||||||
|
.trusted
|
||||||
|
.take()
|
||||||
|
.expect("trusted prompt renderer requires an active prompt");
|
||||||
|
let result = (|| {
|
||||||
|
let size = self.size(trusted.prompt.session_id)?;
|
||||||
|
let layout = PromptLayout::new(&trusted.prompt, size.columns.get(), size.rows.get());
|
||||||
|
self.terminal.write_all(b"\x1b[?2026l\x1b[?2026h")?;
|
||||||
|
self.terminal.write_all(PROMPT_INPUT_MODES_OFF)?;
|
||||||
|
self.terminal.write_all(b"\x1b[?25l\x1b[?7l")?;
|
||||||
|
let entered_alternate = self.physical_screen == PhysicalScreen::Primary;
|
||||||
|
if entered_alternate {
|
||||||
|
self.terminal.write_all(b"\x1b[?1049h")?;
|
||||||
|
self.physical_screen = PhysicalScreen::Alternate;
|
||||||
|
}
|
||||||
|
if reconstruct || entered_alternate {
|
||||||
|
self.terminal
|
||||||
|
.write_all(&self.presentation.context_snapshot())?;
|
||||||
|
}
|
||||||
|
render_prompt_card(
|
||||||
|
&mut self.terminal,
|
||||||
|
&layout,
|
||||||
|
trusted.choice,
|
||||||
|
&mut trusted.view,
|
||||||
|
)?;
|
||||||
|
self.terminal.write_all(b"\x1b[?2026l")?;
|
||||||
|
self.terminal.flush()
|
||||||
|
})();
|
||||||
|
self.trusted = Some(trusted);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_guest_presentation(&mut self) -> io::Result<()> {
|
||||||
|
let trusted = self
|
||||||
|
.trusted
|
||||||
|
.take()
|
||||||
|
.expect("guest restoration requires an active trusted presentation");
|
||||||
|
self.terminal.write_all(b"\x1b[?2026l\x1b[?2026h")?;
|
||||||
|
if trusted.guest_alternate {
|
||||||
|
if self.physical_screen == PhysicalScreen::Primary {
|
||||||
|
self.terminal.write_all(b"\x1b[?1049h")?;
|
||||||
|
self.physical_screen = PhysicalScreen::Alternate;
|
||||||
|
}
|
||||||
|
self.terminal
|
||||||
|
.write_all(&self.presentation.restoration_snapshot())?;
|
||||||
|
} else if self.physical_screen == PhysicalScreen::Alternate {
|
||||||
|
self.terminal.write_all(b"\x1b[?1049l")?;
|
||||||
|
self.physical_screen = PhysicalScreen::Primary;
|
||||||
|
}
|
||||||
|
self.terminal
|
||||||
|
.write_all(&self.presentation.restoration_modes())?;
|
||||||
|
self.terminal.write_all(b"\x1b[?2026l")?;
|
||||||
|
self.terminal
|
||||||
|
.write_all(&self.presentation.synchronized_output_mode())?;
|
||||||
self.terminal.flush()
|
self.terminal.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -788,6 +959,13 @@ fn handle_batch<H: HostTerminal, E>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !terminal.trusted_presentation_active()
|
||||||
|
&& let Some(resize) = terminal.take_deferred_resize()
|
||||||
|
{
|
||||||
|
events.push(InteractiveSessionEventV2::Terminal(
|
||||||
|
SessionTerminalEventV2::Resize(resize),
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(HandledBatch { exit: None, events })
|
Ok(HandledBatch { exit: None, events })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -803,21 +981,39 @@ fn host_event<H: HostTerminal, E>(
|
||||||
event: HostTerminalEvent,
|
event: HostTerminalEvent,
|
||||||
) -> Result<HostEvent, ClientError<E>> {
|
) -> Result<HostEvent, ClientError<E>> {
|
||||||
Ok(match event {
|
Ok(match event {
|
||||||
|
HostTerminalEvent::Input(_) if terminal.trusted_presentation_active() => {
|
||||||
|
HostEvent::Continue
|
||||||
|
}
|
||||||
HostTerminalEvent::Input(bytes) => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
|
HostTerminalEvent::Input(bytes) => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
|
||||||
SessionTerminalEventV2::Input(TerminalInput { session_id, bytes }),
|
SessionTerminalEventV2::Input(TerminalInput { session_id, bytes }),
|
||||||
)),
|
)),
|
||||||
|
HostTerminalEvent::Break if terminal.trusted_presentation_active() => HostEvent::Continue,
|
||||||
HostTerminalEvent::Break => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
|
HostTerminalEvent::Break => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
|
||||||
SessionTerminalEventV2::Break(TerminalBreak { session_id }),
|
SessionTerminalEventV2::Break(TerminalBreak { session_id }),
|
||||||
)),
|
)),
|
||||||
HostTerminalEvent::Resize => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
|
HostTerminalEvent::Resize => {
|
||||||
SessionTerminalEventV2::Resize(terminal.size(session_id)?),
|
let resize = terminal.size(session_id)?;
|
||||||
)),
|
terminal.resize_presentation(&resize)?;
|
||||||
|
if terminal.trusted_presentation_active() {
|
||||||
|
HostEvent::Continue
|
||||||
|
} else {
|
||||||
|
HostEvent::Remote(InteractiveSessionEventV2::Terminal(
|
||||||
|
SessionTerminalEventV2::Resize(resize),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
HostTerminalEvent::LocalSuspend => {
|
HostTerminalEvent::LocalSuspend => {
|
||||||
terminal.suspend_and_resume()?;
|
terminal.suspend_and_resume()?;
|
||||||
|
let resize = terminal.size(session_id)?;
|
||||||
|
terminal.resize_presentation(&resize)?;
|
||||||
|
if terminal.trusted_presentation_active() {
|
||||||
|
HostEvent::Continue
|
||||||
|
} else {
|
||||||
HostEvent::Remote(InteractiveSessionEventV2::Terminal(
|
HostEvent::Remote(InteractiveSessionEventV2::Terminal(
|
||||||
SessionTerminalEventV2::Resize(terminal.size(session_id)?),
|
SessionTerminalEventV2::Resize(resize),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
HostTerminalEvent::Terminate => HostEvent::Disconnect,
|
HostTerminalEvent::Terminate => HostEvent::Disconnect,
|
||||||
HostTerminalEvent::Idle => HostEvent::Continue,
|
HostTerminalEvent::Idle => HostEvent::Continue,
|
||||||
})
|
})
|
||||||
|
|
@ -878,7 +1074,8 @@ fn hex(bytes: &[u8]) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Write as _;
|
use std::io::{self, Read as _, Write as _};
|
||||||
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use lazaret_core::records::TerminalPrompt;
|
use lazaret_core::records::TerminalPrompt;
|
||||||
|
|
@ -886,9 +1083,9 @@ mod tests {
|
||||||
use nix::unistd::Pid;
|
use nix::unistd::Pid;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use rustix::fs::fcntl_getfl;
|
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||||
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
|
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
|
||||||
use rustix::termios::{LocalModes, tcgetattr};
|
use rustix::termios::{LocalModes, Winsize, tcgetattr, tcsetwinsize};
|
||||||
|
|
||||||
use super::prompt::{display_argv, display_bytes};
|
use super::prompt::{display_argv, display_bytes};
|
||||||
use super::{
|
use super::{
|
||||||
|
|
@ -910,6 +1107,16 @@ mod tests {
|
||||||
);
|
);
|
||||||
let original = tcgetattr(&slave).unwrap();
|
let original = tcgetattr(&slave).unwrap();
|
||||||
let original_flags = fcntl_getfl(&slave).unwrap();
|
let original_flags = fcntl_getfl(&slave).unwrap();
|
||||||
|
tcsetwinsize(
|
||||||
|
&slave,
|
||||||
|
Winsize {
|
||||||
|
ws_row: 40,
|
||||||
|
ws_col: 140,
|
||||||
|
ws_xpixel: 0,
|
||||||
|
ws_ypixel: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
(
|
(
|
||||||
SystemTerminal {
|
SystemTerminal {
|
||||||
terminal: slave,
|
terminal: slave,
|
||||||
|
|
@ -921,11 +1128,50 @@ mod tests {
|
||||||
line_start: true,
|
line_start: true,
|
||||||
escape_pending: false,
|
escape_pending: false,
|
||||||
key_translator: super::KeyTranslator::for_profile(Some("xterm-256color")),
|
key_translator: super::KeyTranslator::for_profile(Some("xterm-256color")),
|
||||||
|
presentation: super::PresentationTracker::new(24, 80),
|
||||||
|
trusted: None,
|
||||||
|
physical_screen: super::PhysicalScreen::Primary,
|
||||||
|
deferred_resize: None,
|
||||||
},
|
},
|
||||||
File::from(master),
|
File::from(master),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_available(file: &mut File) -> Vec<u8> {
|
||||||
|
let flags = fcntl_getfl(&*file).unwrap();
|
||||||
|
fcntl_setfl(&*file, flags | OFlags::NONBLOCK).unwrap();
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let mut buffer = [0_u8; 4096];
|
||||||
|
loop {
|
||||||
|
match file.read(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(length) => output.extend_from_slice(&buffer[..length]),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
|
||||||
|
Err(error) => panic!("failed to read terminal output: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
haystack
|
||||||
|
.windows(needle.len())
|
||||||
|
.any(|window| window == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt() -> TerminalPrompt {
|
||||||
|
use lazaret_core::identity::{RequestDigest, RequestId, SessionId};
|
||||||
|
|
||||||
|
TerminalPrompt {
|
||||||
|
session_id: SessionId::new([1; 32]),
|
||||||
|
request_id: RequestId::new([2; 32]),
|
||||||
|
request_sha256: RequestDigest::new([3; 32]),
|
||||||
|
agent_profile: "trusted-agent".into(),
|
||||||
|
command_argv: vec![b"/usr/bin/host-tool".to_vec()],
|
||||||
|
justification: "request host operation".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn raw_mode_forwards_control_bytes_without_local_signals() {
|
fn raw_mode_forwards_control_bytes_without_local_signals() {
|
||||||
let (mut terminal, mut master) = terminal();
|
let (mut terminal, mut master) = terminal();
|
||||||
|
|
@ -1043,16 +1289,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ratatui_prompt_buffer_is_scoped_to_the_centered_opaque_card() {
|
fn ratatui_prompt_buffer_is_scoped_to_the_centered_opaque_card() {
|
||||||
use lazaret_core::identity::{RequestDigest, RequestId, SessionId};
|
let prompt = prompt();
|
||||||
|
|
||||||
let prompt = TerminalPrompt {
|
|
||||||
session_id: SessionId::new([1; 32]),
|
|
||||||
request_id: RequestId::new([2; 32]),
|
|
||||||
request_sha256: RequestDigest::new([3; 32]),
|
|
||||||
agent_profile: "trusted-agent".into(),
|
|
||||||
command_argv: vec![b"/usr/bin/host-tool".to_vec()],
|
|
||||||
justification: "request host operation".into(),
|
|
||||||
};
|
|
||||||
let layout = super::PromptLayout::new(&prompt, 140, 40);
|
let layout = super::PromptLayout::new(&prompt, 140, 40);
|
||||||
let buffer = super::prompt_buffer(
|
let buffer = super::prompt_buffer(
|
||||||
&layout,
|
&layout,
|
||||||
|
|
@ -1075,6 +1312,80 @@ mod tests {
|
||||||
assert!(rendered.contains("Up/Down: review"));
|
assert!(rendered.contains("Up/Down: review"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escalation_card_persists_until_hc_restores_the_primary_guest() {
|
||||||
|
let (mut terminal, mut master) = terminal();
|
||||||
|
terminal.enter_raw().unwrap();
|
||||||
|
terminal.write_output(b"guest context").unwrap();
|
||||||
|
assert_eq!(read_available(&mut master), b"guest context");
|
||||||
|
|
||||||
|
let mut input = master.try_clone().unwrap();
|
||||||
|
let writer = thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_millis(20));
|
||||||
|
input.write_all(b"n").unwrap();
|
||||||
|
});
|
||||||
|
let outcome = terminal.prompt(&prompt()).unwrap();
|
||||||
|
writer.join().unwrap();
|
||||||
|
assert_eq!(outcome.decision, super::DeveloperDecisionKind::Deny);
|
||||||
|
assert!(terminal.trusted.is_some());
|
||||||
|
let card = read_available(&mut master);
|
||||||
|
assert!(contains_bytes(&card, b"LAZARET HOST ESCALATION"));
|
||||||
|
assert!(contains_bytes(&card, b"\x1b[?1049h"));
|
||||||
|
|
||||||
|
let resize = super::TerminalResize {
|
||||||
|
session_id: prompt().session_id,
|
||||||
|
rows: super::NonZeroU16::new(36).unwrap(),
|
||||||
|
columns: super::NonZeroU16::new(120).unwrap(),
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
};
|
||||||
|
terminal.resize_presentation(&resize).unwrap();
|
||||||
|
assert!(terminal.trusted_presentation_active());
|
||||||
|
read_available(&mut master);
|
||||||
|
assert!(matches!(
|
||||||
|
super::host_event::<_, io::Error>(
|
||||||
|
&mut terminal,
|
||||||
|
resize.session_id,
|
||||||
|
HostTerminalEvent::Input(b"inert while host executes".to_vec()),
|
||||||
|
),
|
||||||
|
Ok(super::HostEvent::Continue)
|
||||||
|
));
|
||||||
|
|
||||||
|
terminal.write_output(b"").unwrap();
|
||||||
|
let restoration = read_available(&mut master);
|
||||||
|
assert!(contains_bytes(&restoration, b"\x1b[?1049l"));
|
||||||
|
assert!(!contains_bytes(&restoration, b"guest context"));
|
||||||
|
assert!(terminal.trusted.is_none());
|
||||||
|
assert_eq!(terminal.take_deferred_resize(), Some(resize));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escalation_card_restores_guest_alternate_without_nested_transitions() {
|
||||||
|
let (mut terminal, mut master) = terminal();
|
||||||
|
terminal.enter_raw().unwrap();
|
||||||
|
terminal
|
||||||
|
.write_output(b"\x1b[?1049h\x1b[2J\x1b[Halternate context")
|
||||||
|
.unwrap();
|
||||||
|
read_available(&mut master);
|
||||||
|
|
||||||
|
terminal.trusted = Some(super::TrustedPresentation {
|
||||||
|
prompt: prompt(),
|
||||||
|
choice: PromptChoice::Deny,
|
||||||
|
view: super::PromptView::default(),
|
||||||
|
guest_alternate: true,
|
||||||
|
decision_complete: true,
|
||||||
|
});
|
||||||
|
terminal.render_trusted_prompt(false).unwrap();
|
||||||
|
let card = read_available(&mut master);
|
||||||
|
assert!(!contains_bytes(&card, b"\x1b[?1049h"));
|
||||||
|
|
||||||
|
terminal.write_output(b"").unwrap();
|
||||||
|
let restoration = read_available(&mut master);
|
||||||
|
assert!(contains_bytes(&restoration, b"alternate context"));
|
||||||
|
assert!(!contains_bytes(&restoration, b"\x1b[?1049h"));
|
||||||
|
assert!(!contains_bytes(&restoration, b"\x1b[?1049l"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn qualified_host_keys_translate_to_the_controlled_guest_profile() {
|
fn qualified_host_keys_translate_to_the_controlled_guest_profile() {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
|
||||||
210
crates/lazaret-client/src/terminal/presentation.rs
Normal file
210
crates/lazaret-client/src/terminal/presentation.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
const MAX_SEQUENCE_BYTES: usize = 4096;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
enum ScanState {
|
||||||
|
#[default]
|
||||||
|
Ground,
|
||||||
|
Escape,
|
||||||
|
Csi,
|
||||||
|
DiscardCsi,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct PresentationTracker {
|
||||||
|
parser: vt100::Parser,
|
||||||
|
scan: ScanState,
|
||||||
|
sequence: Vec<u8>,
|
||||||
|
autowrap: bool,
|
||||||
|
focus_reporting: bool,
|
||||||
|
synchronized_output: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresentationTracker {
|
||||||
|
pub(super) fn new(rows: u16, columns: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
parser: vt100::Parser::new(rows, columns, 0),
|
||||||
|
scan: ScanState::Ground,
|
||||||
|
sequence: Vec::new(),
|
||||||
|
autowrap: true,
|
||||||
|
focus_reporting: false,
|
||||||
|
synchronized_output: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn process(&mut self, bytes: &[u8]) {
|
||||||
|
self.parser.process(bytes);
|
||||||
|
for &byte in bytes {
|
||||||
|
self.process_mode_byte(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resize(&mut self, rows: u16, columns: u16) {
|
||||||
|
self.parser.screen_mut().set_size(rows, columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn alternate_screen(&self) -> bool {
|
||||||
|
self.parser.screen().alternate_screen()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn context_snapshot(&self) -> Vec<u8> {
|
||||||
|
let mut bytes = b"\x1b[2J\x1b[H".to_vec();
|
||||||
|
bytes.extend(self.parser.screen().contents_formatted());
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restoration_snapshot(&self) -> Vec<u8> {
|
||||||
|
let mut bytes = b"\x1b[2J\x1b[H".to_vec();
|
||||||
|
bytes.extend(self.parser.screen().state_formatted());
|
||||||
|
bytes.extend(self.additional_modes(false));
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restoration_modes(&self) -> Vec<u8> {
|
||||||
|
let mut bytes = self.parser.screen().input_mode_formatted();
|
||||||
|
bytes.extend(self.additional_modes(false));
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn synchronized_output_mode(&self) -> Vec<u8> {
|
||||||
|
if self.synchronized_output {
|
||||||
|
b"\x1b[?2026h".to_vec()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn additional_modes(&self, synchronized_output: bool) -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend_from_slice(if self.autowrap {
|
||||||
|
b"\x1b[?7h"
|
||||||
|
} else {
|
||||||
|
b"\x1b[?7l"
|
||||||
|
});
|
||||||
|
bytes.extend_from_slice(if self.parser.screen().hide_cursor() {
|
||||||
|
b"\x1b[?25l"
|
||||||
|
} else {
|
||||||
|
b"\x1b[?25h"
|
||||||
|
});
|
||||||
|
bytes.extend_from_slice(if self.focus_reporting {
|
||||||
|
b"\x1b[?1004h"
|
||||||
|
} else {
|
||||||
|
b"\x1b[?1004l"
|
||||||
|
});
|
||||||
|
if synchronized_output && self.synchronized_output {
|
||||||
|
bytes.extend_from_slice(b"\x1b[?2026h");
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_mode_byte(&mut self, byte: u8) {
|
||||||
|
match self.scan {
|
||||||
|
ScanState::Ground => {
|
||||||
|
if byte == 0x1b {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Escape;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScanState::Escape => match byte {
|
||||||
|
b'[' => {
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Csi;
|
||||||
|
}
|
||||||
|
b'c' => {
|
||||||
|
self.autowrap = true;
|
||||||
|
self.focus_reporting = false;
|
||||||
|
self.synchronized_output = false;
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
0x1b => {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ScanState::Csi => {
|
||||||
|
if byte == 0x1b {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Escape;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sequence.push(byte);
|
||||||
|
if self.sequence.len() > MAX_SEQUENCE_BYTES {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::DiscardCsi;
|
||||||
|
} else if (0x40..=0x7e).contains(&byte) {
|
||||||
|
let sequence = std::mem::take(&mut self.sequence);
|
||||||
|
self.apply_mode(&sequence);
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
} else if !(0x20..=0x3f).contains(&byte) {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScanState::DiscardCsi => {
|
||||||
|
if (0x40..=0x7e).contains(&byte) {
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_mode(&mut self, sequence: &[u8]) {
|
||||||
|
let Some((&action, body)) = sequence.split_last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !matches!(action, b'h' | b'l') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(parameters) = body.strip_prefix(b"\x1b[?") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let enabled = action == b'h';
|
||||||
|
for parameter in parameters.split(|byte| *byte == b';') {
|
||||||
|
match parameter {
|
||||||
|
b"7" => self.autowrap = enabled,
|
||||||
|
b"1004" => self.focus_reporting = enabled,
|
||||||
|
b"2026" => self.synchronized_output = enabled,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::PresentationTracker;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presentation_modes_follow_fragmented_guest_output() {
|
||||||
|
let bytes = b"normal\x1b[?7l\x1b[?25l\x1b[?1004;2026h";
|
||||||
|
for split in 0..=bytes.len() {
|
||||||
|
let mut tracker = PresentationTracker::new(8, 32);
|
||||||
|
tracker.process(&bytes[..split]);
|
||||||
|
tracker.process(&bytes[split..]);
|
||||||
|
let mut modes = tracker.restoration_modes();
|
||||||
|
modes.extend(tracker.synchronized_output_mode());
|
||||||
|
assert!(modes.windows(4).any(|window| window == b"[?7l"));
|
||||||
|
assert!(modes.windows(5).any(|window| window == b"[?25l"));
|
||||||
|
assert!(modes.windows(7).any(|window| window == b"[?1004h"));
|
||||||
|
assert!(modes.windows(7).any(|window| window == b"[?2026h"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshots_do_not_replay_an_alternate_transition() {
|
||||||
|
let mut tracker = PresentationTracker::new(8, 32);
|
||||||
|
tracker.process(b"normal\x1b[?1049h\x1b[2J\x1b[Halternate");
|
||||||
|
assert!(tracker.alternate_screen());
|
||||||
|
assert!(
|
||||||
|
!tracker
|
||||||
|
.restoration_snapshot()
|
||||||
|
.windows(8)
|
||||||
|
.any(|window| window == b"[?1049h")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -245,8 +245,11 @@ impl SessionTerminalEngine {
|
||||||
if authoritative != TerminalRoute::Sandbox || terminal.releasing {
|
if authoritative != TerminalRoute::Sandbox || terminal.releasing {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
if let Some(restoration) = terminal.retained.take_restoration() {
|
if terminal.retained.take_restoration() {
|
||||||
terminal.pending_render.extend(restoration);
|
return Ok(vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||||
|
session_id,
|
||||||
|
bytes: Vec::new(),
|
||||||
|
})]);
|
||||||
}
|
}
|
||||||
if let Some(message) = take_rendered_output(terminal, session_id) {
|
if let Some(message) = take_rendered_output(terminal, session_id) {
|
||||||
return Ok(vec![message]);
|
return Ok(vec![message]);
|
||||||
|
|
@ -454,9 +457,10 @@ impl SessionTerminalEngine {
|
||||||
&mut authorize_guest,
|
&mut authorize_guest,
|
||||||
)?;
|
)?;
|
||||||
let mut rendered = terminal.pending_render.drain(..).collect::<Vec<_>>();
|
let mut rendered = terminal.pending_render.drain(..).collect::<Vec<_>>();
|
||||||
|
let restore_presentation = connected && presentation_route != TerminalRoute::Sandbox;
|
||||||
if connected && presentation_route != TerminalRoute::Sandbox {
|
if connected && presentation_route != TerminalRoute::Sandbox {
|
||||||
terminal.retained.obscure_client();
|
terminal.retained.obscure_client();
|
||||||
rendered.extend(terminal.retained.take_restoration().unwrap_or_default());
|
terminal.retained.take_restoration();
|
||||||
}
|
}
|
||||||
for message in output {
|
for message in output {
|
||||||
if let SessionTerminalMessage::Output(output) = message {
|
if let SessionTerminalMessage::Output(output) = message {
|
||||||
|
|
@ -465,15 +469,19 @@ impl SessionTerminalEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rendered.truncate(MAX_DRAIN_BYTES);
|
rendered.truncate(MAX_DRAIN_BYTES);
|
||||||
let mut output = rendered
|
let mut output = Vec::new();
|
||||||
.chunks(MAX_OUTPUT_BYTES)
|
if restore_presentation {
|
||||||
.map(|bytes| {
|
output.push(SessionTerminalMessage::Output(TerminalOutput {
|
||||||
|
session_id,
|
||||||
|
bytes: Vec::new(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
output.extend(rendered.chunks(MAX_OUTPUT_BYTES).map(|bytes| {
|
||||||
SessionTerminalMessage::Output(TerminalOutput {
|
SessionTerminalMessage::Output(TerminalOutput {
|
||||||
session_id,
|
session_id,
|
||||||
bytes: bytes.to_vec(),
|
bytes: bytes.to_vec(),
|
||||||
})
|
})
|
||||||
})
|
}));
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if guest_absent {
|
if guest_absent {
|
||||||
let clean = clean_terminal(generation_id, session_id)?;
|
let clean = clean_terminal(generation_id, session_id)?;
|
||||||
self.recorder.clean(clean)?;
|
self.recorder.clean(clean)?;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
const SYNCHRONIZED_OUTPUT_START: &[u8] = b"\x1b[?2026h";
|
const MAX_CONTROL_SEQUENCE_BYTES: usize = 4096;
|
||||||
const SYNCHRONIZED_OUTPUT_END: &[u8] = b"\x1b[?2026l";
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct TerminalCallbacks {
|
struct TerminalCallbacks {
|
||||||
|
|
@ -21,6 +20,12 @@ impl vt100::Callbacks for TerminalCallbacks {
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
if intermediate_one.is_none()
|
if intermediate_one.is_none()
|
||||||
|
&& intermediate_two.is_none()
|
||||||
|
&& character == 'n'
|
||||||
|
&& first == 5
|
||||||
|
{
|
||||||
|
self.replies.extend_from_slice(b"\x1b[0n");
|
||||||
|
} else if intermediate_one.is_none()
|
||||||
&& intermediate_two.is_none()
|
&& intermediate_two.is_none()
|
||||||
&& character == 'n'
|
&& character == 'n'
|
||||||
&& first == 6
|
&& first == 6
|
||||||
|
|
@ -38,9 +43,30 @@ impl vt100::Callbacks for TerminalCallbacks {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
enum ScanState {
|
||||||
|
#[default]
|
||||||
|
Ground,
|
||||||
|
Escape,
|
||||||
|
EscapeSequence,
|
||||||
|
DiscardEscapeSequence,
|
||||||
|
Csi,
|
||||||
|
DiscardCsi,
|
||||||
|
Osc {
|
||||||
|
escape: bool,
|
||||||
|
},
|
||||||
|
ControlString {
|
||||||
|
escape: bool,
|
||||||
|
},
|
||||||
|
Utf8 {
|
||||||
|
remaining: u8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct RetainedTerminal {
|
pub(super) struct RetainedTerminal {
|
||||||
parser: vt100::Parser<TerminalCallbacks>,
|
parser: vt100::Parser<TerminalCallbacks>,
|
||||||
client_initialized: bool,
|
scan: ScanState,
|
||||||
|
sequence: Vec<u8>,
|
||||||
restore_pending: bool,
|
restore_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,125 +79,398 @@ impl RetainedTerminal {
|
||||||
0,
|
0,
|
||||||
TerminalCallbacks::default(),
|
TerminalCallbacks::default(),
|
||||||
),
|
),
|
||||||
client_initialized: false,
|
scan: ScanState::Ground,
|
||||||
|
sequence: Vec::new(),
|
||||||
restore_pending: false,
|
restore_pending: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn process(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
pub(super) fn process(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||||
let previous = self.parser.screen().clone();
|
let mut forwarded = Vec::with_capacity(bytes.len());
|
||||||
self.parser.process(bytes);
|
for &byte in bytes {
|
||||||
let rendered = if self.client_initialized {
|
self.process_byte(byte, &mut forwarded);
|
||||||
self.parser.screen().state_diff(&previous)
|
}
|
||||||
} else {
|
|
||||||
self.client_initialized = true;
|
|
||||||
self.redraw()
|
|
||||||
};
|
|
||||||
let replies = std::mem::take(&mut self.parser.callbacks_mut().replies);
|
let replies = std::mem::take(&mut self.parser.callbacks_mut().replies);
|
||||||
(rendered, replies)
|
(forwarded, replies)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn resize(&mut self, rows: u16, columns: u16) {
|
pub(super) fn resize(&mut self, rows: u16, columns: u16) {
|
||||||
self.parser.screen_mut().set_size(rows, columns);
|
self.parser.screen_mut().set_size(rows, columns);
|
||||||
if self.client_initialized {
|
|
||||||
self.restore_pending = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn obscure_client(&mut self) {
|
pub(super) fn obscure_client(&mut self) {
|
||||||
self.restore_pending = true;
|
self.restore_pending = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn take_restoration(&mut self) -> Option<Vec<u8>> {
|
pub(super) fn take_restoration(&mut self) -> bool {
|
||||||
if !self.restore_pending {
|
std::mem::take(&mut self.restore_pending)
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.restore_pending = false;
|
|
||||||
self.client_initialized = true;
|
|
||||||
Some(self.redraw())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redraw(&self) -> Vec<u8> {
|
fn process_byte(&mut self, byte: u8, forwarded: &mut Vec<u8>) {
|
||||||
let mut rendered = Vec::new();
|
match self.scan {
|
||||||
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_START);
|
ScanState::Ground => self.process_ground(byte, forwarded),
|
||||||
rendered.extend_from_slice(b"\x1b[2J\x1b[H");
|
ScanState::Escape => self.process_escape(byte, forwarded),
|
||||||
rendered.extend(self.parser.screen().state_formatted());
|
ScanState::EscapeSequence => self.process_escape_sequence(byte, forwarded),
|
||||||
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_END);
|
ScanState::DiscardEscapeSequence => {
|
||||||
rendered
|
if byte == 0x1b {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Escape;
|
||||||
|
} else if (0x30..=0x7e).contains(&byte) {
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ScanState::Csi => self.process_csi(byte, forwarded),
|
||||||
|
ScanState::DiscardCsi => {
|
||||||
|
if (0x40..=0x7e).contains(&byte) {
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScanState::Osc { escape } => {
|
||||||
|
if matches!(byte, 0x07 | 0x9c) || (escape && byte == b'\\') {
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
} else {
|
||||||
|
self.scan = ScanState::Osc {
|
||||||
|
escape: byte == 0x1b,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScanState::ControlString { escape } => {
|
||||||
|
if byte == 0x9c || (escape && byte == b'\\') {
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
} else {
|
||||||
|
self.scan = ScanState::ControlString {
|
||||||
|
escape: byte == 0x1b,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScanState::Utf8 { remaining } => self.process_utf8(byte, remaining, forwarded),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_ground(&mut self, byte: u8, forwarded: &mut Vec<u8>) {
|
||||||
|
match byte {
|
||||||
|
0x1b => {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Escape;
|
||||||
|
}
|
||||||
|
0x90 | 0x98 | 0x9e | 0x9f => {
|
||||||
|
self.scan = ScanState::ControlString { escape: false };
|
||||||
|
}
|
||||||
|
0x9b => self.scan = ScanState::DiscardCsi,
|
||||||
|
0x9d => self.scan = ScanState::Osc { escape: false },
|
||||||
|
0xc2..=0xdf => self.begin_utf8(byte, 1),
|
||||||
|
0xe0..=0xef => self.begin_utf8(byte, 2),
|
||||||
|
0xf0..=0xf4 => self.begin_utf8(byte, 3),
|
||||||
|
0x80..=0xbf | 0xf5..=0xff => {}
|
||||||
|
_ if !byte.is_ascii_control() || matches!(byte, 0x08..=0x0d) => {
|
||||||
|
self.accept(std::slice::from_ref(&byte), forwarded);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_utf8(&mut self, byte: u8, remaining: u8) {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Utf8 { remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_utf8(&mut self, byte: u8, remaining: u8, forwarded: &mut Vec<u8>) {
|
||||||
|
if (0x80..=0xbf).contains(&byte) {
|
||||||
|
self.sequence.push(byte);
|
||||||
|
if remaining == 1 {
|
||||||
|
let sequence = std::mem::take(&mut self.sequence);
|
||||||
|
if std::str::from_utf8(&sequence).is_ok() {
|
||||||
|
self.accept(&sequence, forwarded);
|
||||||
|
}
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
} else {
|
||||||
|
self.scan = ScanState::Utf8 {
|
||||||
|
remaining: remaining - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
self.process_ground(byte, forwarded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_escape(&mut self, byte: u8, forwarded: &mut Vec<u8>) {
|
||||||
|
match byte {
|
||||||
|
b'[' => {
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Csi;
|
||||||
|
}
|
||||||
|
b']' => {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::Osc { escape: false };
|
||||||
|
}
|
||||||
|
b'P' | b'X' | b'^' | b'_' => {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::ControlString { escape: false };
|
||||||
|
}
|
||||||
|
0x20..=0x2f => {
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::EscapeSequence;
|
||||||
|
}
|
||||||
|
0x30..=0x7e => {
|
||||||
|
self.sequence.push(byte);
|
||||||
|
let sequence = std::mem::take(&mut self.sequence);
|
||||||
|
if qualified_escape(&sequence) {
|
||||||
|
self.accept(&sequence, forwarded);
|
||||||
|
}
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
0x1b => {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_escape_sequence(&mut self, byte: u8, forwarded: &mut Vec<u8>) {
|
||||||
|
if (0x20..=0x2f).contains(&byte) {
|
||||||
|
self.sequence.push(byte);
|
||||||
|
if self.sequence.len() > MAX_CONTROL_SEQUENCE_BYTES {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::DiscardEscapeSequence;
|
||||||
|
}
|
||||||
|
} else if (0x30..=0x7e).contains(&byte) {
|
||||||
|
self.sequence.push(byte);
|
||||||
|
let sequence = std::mem::take(&mut self.sequence);
|
||||||
|
if qualified_escape(&sequence) {
|
||||||
|
self.accept(&sequence, forwarded);
|
||||||
|
}
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
} else if byte == 0x1b {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Escape;
|
||||||
|
} else {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_csi(&mut self, byte: u8, forwarded: &mut Vec<u8>) {
|
||||||
|
if byte == 0x1b {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.sequence.push(byte);
|
||||||
|
self.scan = ScanState::Escape;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sequence.push(byte);
|
||||||
|
if self.sequence.len() > MAX_CONTROL_SEQUENCE_BYTES {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::DiscardCsi;
|
||||||
|
} else if (0x40..=0x7e).contains(&byte) {
|
||||||
|
let sequence = std::mem::take(&mut self.sequence);
|
||||||
|
if tracked_query(&sequence) {
|
||||||
|
self.parser.process(&sequence);
|
||||||
|
} else if qualified_csi(&sequence) {
|
||||||
|
self.accept(&sequence, forwarded);
|
||||||
|
}
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
} else if !(0x20..=0x3f).contains(&byte) {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.scan = ScanState::Ground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept(&mut self, bytes: &[u8], forwarded: &mut Vec<u8>) {
|
||||||
|
self.parser.process(bytes);
|
||||||
|
forwarded.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qualified_escape(sequence: &[u8]) -> bool {
|
||||||
|
matches!(
|
||||||
|
sequence,
|
||||||
|
b"\x1b7"
|
||||||
|
| b"\x1b8"
|
||||||
|
| b"\x1bD"
|
||||||
|
| b"\x1bE"
|
||||||
|
| b"\x1bH"
|
||||||
|
| b"\x1bM"
|
||||||
|
| b"\x1b="
|
||||||
|
| b"\x1b>"
|
||||||
|
| b"\x1bc"
|
||||||
|
) || (sequence.len() == 3
|
||||||
|
&& sequence[0] == 0x1b
|
||||||
|
&& matches!(sequence[1], b'(' | b')' | b'*' | b'+' | b'-' | b'.' | b'/')
|
||||||
|
&& (0x30..=0x7e).contains(&sequence[2]))
|
||||||
|
|| sequence == b"\x1b#8"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracked_query(sequence: &[u8]) -> bool {
|
||||||
|
matches!(sequence, b"\x1b[5n" | b"\x1b[6n" | b"\x1b[c" | b"\x1b[0c")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qualified_csi(sequence: &[u8]) -> bool {
|
||||||
|
if sequence.len() < 3 || !sequence.starts_with(b"\x1b[") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let final_byte = sequence[sequence.len() - 1];
|
||||||
|
let body = &sequence[2..sequence.len() - 1];
|
||||||
|
if matches!(final_byte, b'c' | b'n' | b't' | b'i') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if matches!(final_byte, b'h' | b'l') {
|
||||||
|
return qualified_mode(body);
|
||||||
|
}
|
||||||
|
if final_byte == b'q' {
|
||||||
|
return body.last() == Some(&b' ')
|
||||||
|
&& body[..body.len() - 1]
|
||||||
|
.iter()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || *byte == b';');
|
||||||
|
}
|
||||||
|
if !matches!(
|
||||||
|
final_byte,
|
||||||
|
b'@' | b'A'
|
||||||
|
| b'B'
|
||||||
|
| b'C'
|
||||||
|
| b'D'
|
||||||
|
| b'E'
|
||||||
|
| b'F'
|
||||||
|
| b'G'
|
||||||
|
| b'H'
|
||||||
|
| b'I'
|
||||||
|
| b'J'
|
||||||
|
| b'K'
|
||||||
|
| b'L'
|
||||||
|
| b'M'
|
||||||
|
| b'P'
|
||||||
|
| b'S'
|
||||||
|
| b'T'
|
||||||
|
| b'X'
|
||||||
|
| b'Z'
|
||||||
|
| b'`'
|
||||||
|
| b'a'
|
||||||
|
| b'b'
|
||||||
|
| b'd'
|
||||||
|
| b'e'
|
||||||
|
| b'f'
|
||||||
|
| b'g'
|
||||||
|
| b'm'
|
||||||
|
| b'r'
|
||||||
|
| b's'
|
||||||
|
| b'u'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
body.iter()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || matches!(byte, b';' | b':'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qualified_mode(body: &[u8]) -> bool {
|
||||||
|
if let Some(parameters) = body.strip_prefix(b"?") {
|
||||||
|
return parameters.split(|byte| *byte == b';').all(|parameter| {
|
||||||
|
matches!(
|
||||||
|
parameter,
|
||||||
|
b"1" | b"7"
|
||||||
|
| b"12"
|
||||||
|
| b"25"
|
||||||
|
| b"47"
|
||||||
|
| b"1000"
|
||||||
|
| b"1002"
|
||||||
|
| b"1003"
|
||||||
|
| b"1004"
|
||||||
|
| b"1005"
|
||||||
|
| b"1006"
|
||||||
|
| b"1015"
|
||||||
|
| b"1047"
|
||||||
|
| b"1048"
|
||||||
|
| b"1049"
|
||||||
|
| b"2004"
|
||||||
|
| b"2026"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
!body.is_empty()
|
||||||
|
&& body
|
||||||
|
.iter()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || *byte == b';')
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::RetainedTerminal;
|
use super::RetainedTerminal;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parser_and_rendering_are_fragmentation_independent() {
|
fn qualified_stream_and_replies_are_fragmentation_independent() {
|
||||||
let bytes = b"\x1b[2J\x1b[Hplain\xe7\x95\x8c\x1b[2;3H\x1b[1;38;2;12;34;56mstyled\x1b[0m";
|
let bytes = b"plain\x1b[38;2;12;34;56mstyled\x1b[0m\x1b]52;c;ZXZpbA==\x07\x1b[7;11H\x1b[5n\x1b[6n\x1b[c";
|
||||||
let expected = retained_state(&[bytes]);
|
let expected = qualified(&[bytes]);
|
||||||
for split in 0..=bytes.len() {
|
for split in 0..=bytes.len() {
|
||||||
|
assert_eq!(qualified(&[&bytes[..split], &bytes[split..]]), expected);
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
retained_state(&[&bytes[..split], &bytes[split..]]),
|
expected,
|
||||||
expected
|
(
|
||||||
|
b"plain\x1b[38;2;12;34;56mstyled\x1b[0m\x1b[7;11H".to_vec(),
|
||||||
|
b"\x1b[0n\x1b[7;11R\x1b[?1;2c".to_vec(),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_affecting_and_unqualified_controls_are_removed() {
|
||||||
|
let (forwarded, replies) = qualified(&[
|
||||||
|
b"before\x1b]0;title\x07\x1bPpayload\x1b\\\x1b[8;80;120t\x1b[?7727h\x9d0;c1-title\x9c\x9b2Jafter",
|
||||||
|
]);
|
||||||
|
assert_eq!(forwarded, b"beforeafter");
|
||||||
|
assert!(replies.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restoration_reproduces_normal_and_alternate_screen_state() {
|
fn oversized_escape_sequence_is_discarded_without_delaying_later_text() {
|
||||||
for bytes in [
|
let mut bytes = b"before\x1b".to_vec();
|
||||||
b"normal\x1b[3;4H\x1b[?25l".as_slice(),
|
bytes.extend(std::iter::repeat_n(b' ', super::MAX_CONTROL_SEQUENCE_BYTES));
|
||||||
b"normal\x1b[?1049h\x1b[2J\x1b[Halternate\x1b[4;5H".as_slice(),
|
bytes.extend_from_slice(b"0after");
|
||||||
] {
|
assert_eq!(qualified(&[&bytes]).0, b"beforeafter");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_text_remains_fragmentation_independent_without_raw_c1_controls() {
|
||||||
|
let bytes = "plain界🙂".as_bytes();
|
||||||
|
let expected = qualified(&[bytes]);
|
||||||
|
assert_eq!(expected.0, bytes);
|
||||||
|
for split in 0..=bytes.len() {
|
||||||
|
assert_eq!(qualified(&[&bytes[..split], &bytes[split..]]), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qualified_terminal_modes_and_alternate_screen_are_forwarded() {
|
||||||
|
let bytes = b"\x1b[?1;7;25;1002;1004;1006;1049;2004;2026h\x1b[?2026;2004;1049;1006;1004;1002;25;7;1l";
|
||||||
|
assert_eq!(qualified(&[bytes]).0, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restoration_is_one_route_boundary_without_rendered_bytes() {
|
||||||
let mut terminal = RetainedTerminal::new(8, 32);
|
let mut terminal = RetainedTerminal::new(8, 32);
|
||||||
let (initial, _) = terminal.process(bytes);
|
terminal.process(b"content");
|
||||||
|
assert!(!terminal.take_restoration());
|
||||||
terminal.obscure_client();
|
terminal.obscure_client();
|
||||||
let restored = terminal.take_restoration().unwrap();
|
assert!(terminal.take_restoration());
|
||||||
assert_eq!(rendered_state(&initial), rendered_state(&restored));
|
assert!(!terminal.take_restoration());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn qualified(chunks: &[&[u8]]) -> (Vec<u8>, Vec<u8>) {
|
||||||
fn terminal_replies_derive_from_retained_cursor() {
|
|
||||||
let mut terminal = RetainedTerminal::new(24, 80);
|
let mut terminal = RetainedTerminal::new(24, 80);
|
||||||
let (_, replies) = terminal.process(b"\x1b[7;11H\x1b[6n\x1b[c");
|
let mut forwarded = Vec::new();
|
||||||
assert_eq!(replies, b"\x1b[7;11R\x1b[?1;2c");
|
let mut replies = Vec::new();
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resize_restores_at_the_new_size() {
|
|
||||||
let mut terminal = RetainedTerminal::new(8, 32);
|
|
||||||
terminal.process(b"content\x1b[6;7H");
|
|
||||||
terminal.resize(6, 24);
|
|
||||||
let restored = terminal.take_restoration().unwrap();
|
|
||||||
let mut parser = vt100::Parser::new(6, 24, 0);
|
|
||||||
parser.process(&restored);
|
|
||||||
assert_eq!(parser.screen().size(), (6, 24));
|
|
||||||
assert!(parser.screen().contents().contains("content"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_before_guest_output_restores_a_blank_sandbox_screen() {
|
|
||||||
let mut terminal = RetainedTerminal::new(8, 32);
|
|
||||||
terminal.obscure_client();
|
|
||||||
let restored = terminal.take_restoration().unwrap();
|
|
||||||
let mut parser = vt100::Parser::new(8, 32, 0);
|
|
||||||
parser.process(&restored);
|
|
||||||
assert!(parser.screen().contents().trim().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn retained_state(chunks: &[&[u8]]) -> Vec<u8> {
|
|
||||||
let mut terminal = RetainedTerminal::new(8, 32);
|
|
||||||
for chunk in chunks {
|
for chunk in chunks {
|
||||||
terminal.process(chunk);
|
let (chunk, response) = terminal.process(chunk);
|
||||||
|
forwarded.extend(chunk);
|
||||||
|
replies.extend(response);
|
||||||
}
|
}
|
||||||
terminal.obscure_client();
|
(forwarded, replies)
|
||||||
terminal.take_restoration().unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rendered_state(bytes: &[u8]) -> Vec<u8> {
|
|
||||||
let mut parser = vt100::Parser::new(8, 32, 0);
|
|
||||||
parser.process(bytes);
|
|
||||||
parser.screen().state_formatted()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,28 +42,28 @@ fn merged_output_is_ordered_and_prompt_route_isolated_by_session() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_and_host_process_presentations_restore_the_retained_screen() {
|
fn prompt_and_host_process_emit_one_client_restoration_boundary() {
|
||||||
let session = id(6);
|
let session = id(6);
|
||||||
let (fixture, factory, mut engine) = setup(&[session]);
|
let (fixture, factory, mut engine) = setup(&[session]);
|
||||||
engine.acquire(&acquisition(session)).unwrap();
|
engine.acquire(&acquisition(session)).unwrap();
|
||||||
factory.state(0).lock().unwrap().output.push_back(
|
let guest_output = b"\x1b[2J\x1b[Hnormal\x1b[?1049h\x1b[2J\x1b[H\x1b[1;38;2;12;34;56malternate\x1b[0m\x1b[4;5H\x1b[?25l";
|
||||||
b"\x1b[2J\x1b[Hnormal\x1b[?1049h\x1b[2J\x1b[H\x1b[1;38;2;12;34;56malternate\x1b[0m\x1b[4;5H\x1b[?25l".to_vec(),
|
factory
|
||||||
);
|
.state(0)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.output
|
||||||
|
.push_back(guest_output.to_vec());
|
||||||
let initial = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
let initial = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
||||||
let mut client = vt100::Parser::new(24, 80, 0);
|
assert_eq!(initial, guest_output);
|
||||||
client.process(&initial);
|
|
||||||
let expected = client.screen().state_formatted();
|
|
||||||
|
|
||||||
fixture.set_session_phase(session, SessionPhase::Prompting);
|
fixture.set_session_phase(session, SessionPhase::Prompting);
|
||||||
engine
|
engine
|
||||||
.set_route(session, GenerationId::new(7), TerminalRoute::Prompt)
|
.set_route(session, GenerationId::new(7), TerminalRoute::Prompt)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.process(b"\x1b[2J\x1b[HPROMPT\x1b[24;80H");
|
|
||||||
fixture.set_session_phase(session, SessionPhase::HostExecuting);
|
fixture.set_session_phase(session, SessionPhase::HostExecuting);
|
||||||
engine
|
engine
|
||||||
.set_route(session, GenerationId::new(7), TerminalRoute::HostProcess)
|
.set_route(session, GenerationId::new(7), TerminalRoute::HostProcess)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
client.process(b"\x1b[2J\x1b[HHOST PROCESS\x1b[?25h");
|
|
||||||
assert!(
|
assert!(
|
||||||
engine
|
engine
|
||||||
.poll(session, GenerationId::new(7))
|
.poll(session, GenerationId::new(7))
|
||||||
|
|
@ -75,10 +75,26 @@ fn prompt_and_host_process_presentations_restore_the_retained_screen() {
|
||||||
engine
|
engine
|
||||||
.set_route(session, GenerationId::new(7), TerminalRoute::Sandbox)
|
.set_route(session, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let restored = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
let restored = engine.poll(session, GenerationId::new(7)).unwrap();
|
||||||
client.process(&restored);
|
let [SessionTerminalMessage::Output(restoration)] = restored.as_slice() else {
|
||||||
assert_eq!(client.screen().state_formatted(), expected);
|
panic!("expected one presentation restoration boundary");
|
||||||
|
};
|
||||||
|
assert!(restoration.bytes.is_empty());
|
||||||
assert!(factory.state(0).lock().unwrap().input.is_empty());
|
assert!(factory.state(0).lock().unwrap().input.is_empty());
|
||||||
|
|
||||||
|
factory
|
||||||
|
.state(0)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.output
|
||||||
|
.push_back(b"\x1b[6n".to_vec());
|
||||||
|
assert!(
|
||||||
|
engine
|
||||||
|
.poll(session, GenerationId::new(7))
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(factory.state(0).lock().unwrap().input, b"\x1b[4;5R");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue