Preserve native terminal behavior through trusted prompts

This commit is contained in:
ms2k 2026-08-20 05:29:43 +09:00
commit f989f3bc01
3 changed files with 997 additions and 151 deletions

View file

@ -25,6 +25,7 @@ sha2.workspace = true
nix.workspace = true
ratatui.workspace = true
serde_json.workspace = true
vt100.workspace = true
[dev-dependencies]
tempfile = "=3.27.0"

View file

@ -27,8 +27,10 @@ use rustix::termios::{
use crate::{ClientError, ClientSessionIdentity};
mod presentation;
mod prompt;
use presentation::PresentationTracker;
use prompt::{
PromptChoice, PromptInput, PromptInputDecoder, PromptLayout, PromptLineKind, PromptView,
prompt_outcome,
@ -85,6 +87,17 @@ pub trait HostTerminal {
/// Returns an error while writing merged terminal output.
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()>;
/// # 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.
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome>;
/// # Errors
@ -110,8 +123,31 @@ pub struct SystemTerminal {
line_start: bool,
escape_pending: bool,
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 {
mappings: &'static [(&'static [u8], &'static [u8])],
pending: Vec<u8>,
@ -210,6 +246,7 @@ impl SystemTerminal {
/// Returns an error when the process has no usable controlling terminal.
pub fn open() -> io::Result<Self> {
let terminal = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
let size = tcgetwinsize(&terminal)?;
Ok(Self {
original: tcgetattr(&terminal)?,
original_flags: fcntl_getfl(&terminal)?,
@ -220,6 +257,10 @@ impl SystemTerminal {
line_start: true,
escape_pending: false,
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 {
return Ok(());
}
self.terminal.write_all(b"\x1b[?7h\x1b[?25h")?;
self.terminal.flush()?;
self.leave_for_host()?;
self.trusted = None;
self.deferred_resize = None;
fcntl_setfl(&self.terminal, self.original_flags)?;
tcsetattr(&self.terminal, OptionalActions::Flush, &self.original)?;
self.raw = false;
@ -347,18 +389,61 @@ impl HostTerminal for SystemTerminal {
}
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.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> {
if self.trusted.is_some() {
return Err(io::Error::other("terminal prompt is already active"));
}
self.events.clear();
self.escape_pending = false;
self.key_translator.clear();
tcflush(&self.terminal, QueueSelector::IFlush)?;
let mut decoder = PromptInputDecoder::new();
let mut view = PromptView::default();
self.render_prompt(prompt, decoder.choice, &mut view, false)?;
self.trusted = Some(TrustedPresentation {
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;
loop {
match self.next_event()? {
@ -367,32 +452,41 @@ impl HostTerminal for SystemTerminal {
let input = decoder.feed(&bytes, Instant::now());
let mut reviewed = false;
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;
}
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 {
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 => {
self.suspend_and_resume()?;
resumed_size = Some(self.size(prompt.session_id)?);
self.render_prompt(prompt, decoder.choice, &mut view, true)?;
let resize = self.size(prompt.session_id)?;
self.resize_presentation(&resize)?;
resumed_size = Some(resize);
}
HostTerminalEvent::Terminate => {
self.write_output(b"\x1b[?7h\x1b[?25h")?;
return Err(io::ErrorKind::Interrupted.into());
}
HostTerminalEvent::Resize => {
resumed_size = Some(self.size(prompt.session_id)?);
self.render_prompt(prompt, decoder.choice, &mut view, true)?;
let resize = self.size(prompt.session_id)?;
self.resize_presentation(&resize)?;
resumed_size = Some(resize);
}
HostTerminalEvent::Idle => {
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 => {}
@ -401,33 +495,110 @@ impl HostTerminal for SystemTerminal {
}
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()?;
self.enter_raw()
self.enter_raw()?;
self.reconstruct_after_resume()
}
}
impl SystemTerminal {
fn complete_prompt(&mut self, outcome: PromptOutcome) -> io::Result<PromptOutcome> {
self.write_output(b"\x1b[?7h\x1b[?25h")?;
Ok(outcome)
fn complete_prompt_decision(
&mut self,
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(
&mut self,
prompt: &TerminalPrompt,
choice: PromptChoice,
view: &mut PromptView,
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")?;
fn leave_for_host(&mut self) -> io::Result<()> {
self.terminal.write_all(HOST_TERMINAL_MODES)?;
if self.physical_screen == PhysicalScreen::Alternate {
self.terminal.write_all(b"\x1b[?1049l")?;
self.physical_screen = PhysicalScreen::Primary;
}
render_prompt_card(&mut self.terminal, &layout, choice, view)?;
self.terminal.write_all(b"\x1b[?7h\x1b[?2026l")?;
self.terminal.flush()
}
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()
}
}
@ -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 })
}
@ -803,20 +981,38 @@ fn host_event<H: HostTerminal, E>(
event: HostTerminalEvent,
) -> Result<HostEvent, ClientError<E>> {
Ok(match event {
HostTerminalEvent::Input(_) if terminal.trusted_presentation_active() => {
HostEvent::Continue
}
HostTerminalEvent::Input(bytes) => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Input(TerminalInput { session_id, bytes }),
)),
HostTerminalEvent::Break if terminal.trusted_presentation_active() => HostEvent::Continue,
HostTerminalEvent::Break => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Break(TerminalBreak { session_id }),
)),
HostTerminalEvent::Resize => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Resize(terminal.size(session_id)?),
)),
HostTerminalEvent::Resize => {
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 => {
terminal.suspend_and_resume()?;
HostEvent::Remote(InteractiveSessionEventV2::Terminal(
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::Terminate => HostEvent::Disconnect,
HostTerminalEvent::Idle => HostEvent::Continue,
@ -878,7 +1074,8 @@ fn hex(bytes: &[u8]) -> String {
mod tests {
use std::collections::VecDeque;
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 lazaret_core::records::TerminalPrompt;
@ -886,9 +1083,9 @@ mod tests {
use nix::unistd::Pid;
use ratatui::layout::Rect;
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::termios::{LocalModes, tcgetattr};
use rustix::termios::{LocalModes, Winsize, tcgetattr, tcsetwinsize};
use super::prompt::{display_argv, display_bytes};
use super::{
@ -910,6 +1107,16 @@ mod tests {
);
let original = tcgetattr(&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 {
terminal: slave,
@ -921,11 +1128,50 @@ mod tests {
line_start: true,
escape_pending: false,
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),
)
}
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]
fn raw_mode_forwards_control_bytes_without_local_signals() {
let (mut terminal, mut master) = terminal();
@ -1043,16 +1289,7 @@ mod tests {
#[test]
fn ratatui_prompt_buffer_is_scoped_to_the_centered_opaque_card() {
use lazaret_core::identity::{RequestDigest, RequestId, SessionId};
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 prompt = prompt();
let layout = super::PromptLayout::new(&prompt, 140, 40);
let buffer = super::prompt_buffer(
&layout,
@ -1075,6 +1312,80 @@ mod tests {
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]
fn qualified_host_keys_translate_to_the_controlled_guest_profile() {
let start = Instant::now();

View 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")
);
}
}