Implement retained virtual terminal presentation
This commit is contained in:
parent
aef24440a5
commit
f468592d95
17 changed files with 811 additions and 71 deletions
29
Cargo.lock
generated
29
Cargo.lock
generated
|
|
@ -82,6 +82,12 @@ version = "1.0.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -1743,6 +1749,7 @@ dependencies = [
|
||||||
name = "lazaret-host-coordinator"
|
name = "lazaret-host-coordinator"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
|
|
@ -1770,6 +1777,7 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"vt100",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3350,6 +3358,27 @@ version = "0.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vt100"
|
||||||
|
version = "0.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"unicode-width",
|
||||||
|
"vte",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ license = "Apache-2.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "=1.0.103"
|
anyhow = "=1.0.103"
|
||||||
|
base64 = "=0.22.1"
|
||||||
bstr = "=1.13.0"
|
bstr = "=1.13.0"
|
||||||
clap = { version = "=4.6.2", features = ["derive"] }
|
clap = { version = "=4.6.2", features = ["derive"] }
|
||||||
ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] }
|
ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] }
|
||||||
|
|
@ -53,6 +54,7 @@ tar = "=0.4.46"
|
||||||
time = "=0.3.53"
|
time = "=0.3.53"
|
||||||
tokio = { version = "=1.52.3", features = ["fs", "io-util", "process", "rt"] }
|
tokio = { version = "=1.52.3", features = ["fs", "io-util", "process", "rt"] }
|
||||||
toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] }
|
toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] }
|
||||||
|
vt100 = "=0.16.2"
|
||||||
xattr = "=1.6.1"
|
xattr = "=1.6.1"
|
||||||
x509-parser = "=0.18.1"
|
x509-parser = "=0.18.1"
|
||||||
zstd = "=0.13.3"
|
zstd = "=0.13.3"
|
||||||
|
|
|
||||||
|
|
@ -645,6 +645,12 @@ impl<E> From<io::Error> for ClientError<E> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<E> From<ControlClientError> for ClientError<E> {
|
||||||
|
fn from(error: ControlClientError) -> Self {
|
||||||
|
Self::Control(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -671,9 +677,3 @@ mod tests {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E> From<ControlClientError> for ClientError<E> {
|
|
||||||
fn from(error: ControlClientError) -> Self {
|
|
||||||
Self::Control(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::collections::VecDeque;
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::num::NonZeroU16;
|
use std::num::NonZeroU16;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use lazaret_core::records::{
|
use lazaret_core::records::{
|
||||||
DeveloperDecisionKind, InteractiveProtocolVersion, InteractiveSessionBatchV2,
|
DeveloperDecisionKind, InteractiveProtocolVersion, InteractiveSessionBatchV2,
|
||||||
|
|
@ -14,11 +14,18 @@ use nix::sys::signal::{SigSet, SigmaskHow, Signal};
|
||||||
use nix::sys::signalfd::{SfdFlags, SignalFd};
|
use nix::sys::signalfd::{SfdFlags, SignalFd};
|
||||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||||
use rustix::termios::{
|
use rustix::termios::{
|
||||||
OptionalActions, SpecialCodeIndex, Termios, tcgetattr, tcgetwinsize, tcsetattr,
|
OptionalActions, QueueSelector, SpecialCodeIndex, Termios, tcflush, tcgetattr, tcgetwinsize,
|
||||||
|
tcsetattr,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{ClientError, ClientSessionIdentity};
|
use crate::{ClientError, ClientSessionIdentity};
|
||||||
|
|
||||||
|
mod prompt;
|
||||||
|
|
||||||
|
use prompt::{
|
||||||
|
PromptChoice, PromptInput, PromptInputDecoder, display_argv, display_bytes, prompt_outcome,
|
||||||
|
};
|
||||||
|
|
||||||
pub trait InteractiveSessionTransport {
|
pub trait InteractiveSessionTransport {
|
||||||
type Error;
|
type Error;
|
||||||
|
|
||||||
|
|
@ -83,6 +90,8 @@ pub struct PromptOutcome {
|
||||||
pub resumed_size: Option<TerminalResize>,
|
pub resumed_size: Option<TerminalResize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROMPT_ESCAPE_INTERVAL: Duration = Duration::from_millis(40);
|
||||||
|
|
||||||
pub struct SystemTerminal {
|
pub struct SystemTerminal {
|
||||||
terminal: File,
|
terminal: File,
|
||||||
original: Termios,
|
original: Termios,
|
||||||
|
|
@ -92,8 +101,100 @@ pub struct SystemTerminal {
|
||||||
events: VecDeque<HostTerminalEvent>,
|
events: VecDeque<HostTerminalEvent>,
|
||||||
line_start: bool,
|
line_start: bool,
|
||||||
escape_pending: bool,
|
escape_pending: bool,
|
||||||
|
key_translator: KeyTranslator,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct KeyTranslator {
|
||||||
|
mappings: &'static [(&'static [u8], &'static [u8])],
|
||||||
|
pending: Vec<u8>,
|
||||||
|
deadline: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyTranslator {
|
||||||
|
fn for_profile(profile: Option<&str>) -> Self {
|
||||||
|
let mappings = match profile {
|
||||||
|
Some("xterm-256color" | "alacritty" | "foot") => XTERM_KEY_MAPPINGS,
|
||||||
|
Some("linux") => LINUX_KEY_MAPPINGS,
|
||||||
|
_ => &[],
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
mappings,
|
||||||
|
pending: Vec::new(),
|
||||||
|
deadline: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed(&mut self, bytes: &[u8], now: Instant) -> Vec<u8> {
|
||||||
|
if self.mappings.is_empty() {
|
||||||
|
return bytes.to_vec();
|
||||||
|
}
|
||||||
|
let mut translated = if self.deadline.is_some_and(|deadline| now >= deadline) {
|
||||||
|
self.deadline = None;
|
||||||
|
std::mem::take(&mut self.pending)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
for &byte in bytes {
|
||||||
|
self.pending.push(byte);
|
||||||
|
loop {
|
||||||
|
if let Some((_, target)) = self
|
||||||
|
.mappings
|
||||||
|
.iter()
|
||||||
|
.find(|(source, _)| *source == self.pending)
|
||||||
|
{
|
||||||
|
translated.extend_from_slice(target);
|
||||||
|
self.pending.clear();
|
||||||
|
self.deadline = None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.mappings
|
||||||
|
.iter()
|
||||||
|
.any(|(source, _)| source.starts_with(&self.pending))
|
||||||
|
{
|
||||||
|
self.deadline = Some(now + PROMPT_ESCAPE_INTERVAL);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
translated.push(self.pending.remove(0));
|
||||||
|
if self.pending.is_empty() {
|
||||||
|
self.deadline = None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
translated
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expire(&mut self, now: Instant) -> Option<Vec<u8>> {
|
||||||
|
if self.deadline.is_some_and(|deadline| now >= deadline) {
|
||||||
|
self.deadline = None;
|
||||||
|
Some(std::mem::take(&mut self.pending))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear(&mut self) {
|
||||||
|
self.pending.clear();
|
||||||
|
self.deadline = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const XTERM_KEY_MAPPINGS: &[(&[u8], &[u8])] = &[(b"\x1bOH", b"\x1b[1~"), (b"\x1bOF", b"\x1b[4~")];
|
||||||
|
|
||||||
|
const LINUX_KEY_MAPPINGS: &[(&[u8], &[u8])] = &[
|
||||||
|
(b"\x1b[A", b"\x1bOA"),
|
||||||
|
(b"\x1b[B", b"\x1bOB"),
|
||||||
|
(b"\x1b[D", b"\x1bOD"),
|
||||||
|
(b"\x1b[C", b"\x1bOC"),
|
||||||
|
(b"\x1b\t", b"\x1b[Z"),
|
||||||
|
(b"\x1b[[A", b"\x1bOP"),
|
||||||
|
(b"\x1b[[B", b"\x1bOQ"),
|
||||||
|
(b"\x1b[[C", b"\x1bOR"),
|
||||||
|
(b"\x1b[[D", b"\x1bOS"),
|
||||||
|
(b"\x1b[[E", b"\x1b[15~"),
|
||||||
|
];
|
||||||
|
|
||||||
impl SystemTerminal {
|
impl SystemTerminal {
|
||||||
/// Opens the controlling terminal and captures its exact initial mode.
|
/// Opens the controlling terminal and captures its exact initial mode.
|
||||||
///
|
///
|
||||||
|
|
@ -110,6 +211,7 @@ impl SystemTerminal {
|
||||||
events: VecDeque::new(),
|
events: VecDeque::new(),
|
||||||
line_start: true,
|
line_start: true,
|
||||||
escape_pending: false,
|
escape_pending: false,
|
||||||
|
key_translator: KeyTranslator::for_profile(std::env::var("TERM").ok().as_deref()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,7 +258,10 @@ impl SystemTerminal {
|
||||||
self.line_start = matches!(byte, b'\r' | b'\n');
|
self.line_start = matches!(byte, b'\r' | b'\n');
|
||||||
}
|
}
|
||||||
if !forwarded.is_empty() {
|
if !forwarded.is_empty() {
|
||||||
events.push_back(HostTerminalEvent::Input(forwarded));
|
let translated = self.key_translator.feed(&forwarded, Instant::now());
|
||||||
|
if !translated.is_empty() {
|
||||||
|
events.push_back(HostTerminalEvent::Input(translated));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.events.extend(events);
|
self.events.extend(events);
|
||||||
}
|
}
|
||||||
|
|
@ -209,6 +314,9 @@ impl HostTerminal for SystemTerminal {
|
||||||
if let Some(event) = self.events.pop_front() {
|
if let Some(event) = self.events.pop_front() {
|
||||||
return Ok(event);
|
return Ok(event);
|
||||||
}
|
}
|
||||||
|
if let Some(bytes) = self.key_translator.expire(Instant::now()) {
|
||||||
|
return Ok(HostTerminalEvent::Input(bytes));
|
||||||
|
}
|
||||||
if let Some(event) = self.signals.next_event()? {
|
if let Some(event) = self.signals.next_event()? {
|
||||||
return Ok(event);
|
return Ok(event);
|
||||||
}
|
}
|
||||||
|
|
@ -234,39 +342,40 @@ impl HostTerminal for SystemTerminal {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
|
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
|
||||||
self.write_output(
|
self.events.clear();
|
||||||
format!(
|
self.escape_pending = false;
|
||||||
"\r\nLazaret host request\r\nprofile: {}\r\nsession: {}\r\ncommand: {}\r\njustification: {}\r\nApprove? [y/N] ",
|
self.key_translator.clear();
|
||||||
prompt.agent_profile,
|
tcflush(&self.terminal, QueueSelector::IFlush)?;
|
||||||
hex(prompt.session_id.as_bytes()),
|
let mut decoder = PromptInputDecoder::new();
|
||||||
display_argv(&prompt.command_argv),
|
self.render_prompt(prompt, decoder.choice)?;
|
||||||
prompt.justification
|
|
||||||
)
|
|
||||||
.as_bytes(),
|
|
||||||
)?;
|
|
||||||
let mut resumed_size = None;
|
let mut resumed_size = None;
|
||||||
loop {
|
loop {
|
||||||
match self.next_event()? {
|
match self.next_event()? {
|
||||||
HostTerminalEvent::Input(bytes) => {
|
HostTerminalEvent::Input(bytes) => {
|
||||||
let approve = bytes
|
let previous = decoder.choice;
|
||||||
.first()
|
if let PromptInput::Decide(choice) = decoder.feed(&bytes, Instant::now()) {
|
||||||
.is_some_and(|byte| matches!(byte, b'y' | b'Y'));
|
return Ok(prompt_outcome(choice, resumed_size));
|
||||||
self.write_output(if approve { b"y\r\n" } else { b"n\r\n" })?;
|
}
|
||||||
return Ok(PromptOutcome {
|
if decoder.choice != previous {
|
||||||
decision: if approve {
|
self.render_prompt(prompt, decoder.choice)?;
|
||||||
DeveloperDecisionKind::Approve
|
}
|
||||||
} else {
|
|
||||||
DeveloperDecisionKind::Deny
|
|
||||||
},
|
|
||||||
resumed_size,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
HostTerminalEvent::LocalSuspend => {
|
HostTerminalEvent::LocalSuspend => {
|
||||||
self.suspend_and_resume()?;
|
self.suspend_and_resume()?;
|
||||||
resumed_size = Some(self.size(prompt.session_id)?);
|
resumed_size = Some(self.size(prompt.session_id)?);
|
||||||
|
self.render_prompt(prompt, decoder.choice)?;
|
||||||
}
|
}
|
||||||
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
|
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
|
||||||
HostTerminalEvent::Idle | HostTerminalEvent::Resize | HostTerminalEvent::Break => {}
|
HostTerminalEvent::Resize => {
|
||||||
|
resumed_size = Some(self.size(prompt.session_id)?);
|
||||||
|
self.render_prompt(prompt, decoder.choice)?;
|
||||||
|
}
|
||||||
|
HostTerminalEvent::Idle => {
|
||||||
|
if let PromptInput::Decide(choice) = decoder.expire(Instant::now()) {
|
||||||
|
return Ok(prompt_outcome(choice, resumed_size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostTerminalEvent::Break => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -278,6 +387,33 @@ impl HostTerminal for SystemTerminal {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SystemTerminal {
|
||||||
|
fn render_prompt(&mut self, prompt: &TerminalPrompt, choice: PromptChoice) -> io::Result<()> {
|
||||||
|
let approve = if choice == PromptChoice::Approve {
|
||||||
|
"[ Approve ]"
|
||||||
|
} else {
|
||||||
|
" Approve "
|
||||||
|
};
|
||||||
|
let deny = if choice == PromptChoice::Deny {
|
||||||
|
"[ Deny ]"
|
||||||
|
} else {
|
||||||
|
" Deny "
|
||||||
|
};
|
||||||
|
self.write_output(
|
||||||
|
format!(
|
||||||
|
"\x1b[?2026h\x1b[2J\x1b[HLazaret host request\r\nrequester: {} / {}\r\ncommand: {}\r\njustification: {}\r\n\r\n{} {}\r\n\x1b[?2026l",
|
||||||
|
display_bytes(prompt.agent_profile.as_bytes()),
|
||||||
|
hex(prompt.session_id.as_bytes()),
|
||||||
|
display_argv(&prompt.command_argv),
|
||||||
|
display_bytes(prompt.justification.as_bytes()),
|
||||||
|
approve,
|
||||||
|
deny,
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for SystemTerminal {
|
impl Drop for SystemTerminal {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.restore();
|
let _ = self.restore();
|
||||||
|
|
@ -580,13 +716,6 @@ fn require_session<E>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_argv(argv: &[Vec<u8>]) -> String {
|
|
||||||
argv.iter()
|
|
||||||
.map(|argument| String::from_utf8_lossy(argument))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex(bytes: &[u8]) -> String {
|
fn hex(bytes: &[u8]) -> String {
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
|
@ -601,6 +730,7 @@ 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::Write as _;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
|
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
|
||||||
use nix::unistd::Pid;
|
use nix::unistd::Pid;
|
||||||
|
|
@ -608,7 +738,10 @@ mod tests {
|
||||||
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, tcgetattr};
|
||||||
|
|
||||||
use super::{HostTerminal, HostTerminalEvent, SignalQueue, SystemTerminal};
|
use super::{
|
||||||
|
HostTerminal, HostTerminalEvent, PromptChoice, PromptInput, PromptInputDecoder,
|
||||||
|
SignalQueue, SystemTerminal, display_argv, display_bytes,
|
||||||
|
};
|
||||||
|
|
||||||
fn terminal() -> (SystemTerminal, File) {
|
fn terminal() -> (SystemTerminal, File) {
|
||||||
let master =
|
let master =
|
||||||
|
|
@ -634,6 +767,7 @@ mod tests {
|
||||||
events: VecDeque::new(),
|
events: VecDeque::new(),
|
||||||
line_start: true,
|
line_start: true,
|
||||||
escape_pending: false,
|
escape_pending: false,
|
||||||
|
key_translator: super::KeyTranslator::for_profile(Some("xterm-256color")),
|
||||||
},
|
},
|
||||||
File::from(master),
|
File::from(master),
|
||||||
)
|
)
|
||||||
|
|
@ -674,6 +808,105 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_decoder_is_fragmentation_independent_and_fails_closed() {
|
||||||
|
for interval in [
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
Duration::from_millis(20),
|
||||||
|
] {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut decoder = PromptInputDecoder::new();
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.feed(b"\x1b[", start),
|
||||||
|
PromptInput::Continue
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.feed(b"D", start + interval),
|
||||||
|
PromptInput::Continue
|
||||||
|
));
|
||||||
|
assert_eq!(decoder.choice, PromptChoice::Approve);
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.feed(b"\r", start + interval),
|
||||||
|
PromptInput::Decide(PromptChoice::Approve)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut decoder = PromptInputDecoder::new();
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.feed(b"\x1b", start),
|
||||||
|
PromptInput::Continue
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.expire(start + Duration::from_millis(41)),
|
||||||
|
PromptInput::Decide(PromptChoice::Deny)
|
||||||
|
));
|
||||||
|
let mut decoder = PromptInputDecoder::new();
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.feed(b"\x1b[", start),
|
||||||
|
PromptInput::Continue
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.feed(b"D", start + Duration::from_millis(41)),
|
||||||
|
PromptInput::Decide(PromptChoice::Deny)
|
||||||
|
));
|
||||||
|
for bytes in [b"\r".as_slice(), b"\x03", b"unexpected", b"\x1b[Z"] {
|
||||||
|
assert!(matches!(
|
||||||
|
PromptInputDecoder::new().feed(bytes, start),
|
||||||
|
PromptInput::Decide(PromptChoice::Deny)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
PromptInputDecoder::new().feed(b"y", start),
|
||||||
|
PromptInput::Decide(PromptChoice::Approve)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_fields_are_inert_and_arguments_remain_separate() {
|
||||||
|
assert_eq!(display_bytes(b"ok\x1b[2J\n"), "ok\\x1b[2J\\x0a");
|
||||||
|
assert_eq!(
|
||||||
|
display_argv(&[b"one two".to_vec(), b"three\x1b[H".to_vec()]),
|
||||||
|
"[\"one two\", \"three\\x1b[H\"]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qualified_host_keys_translate_to_the_controlled_guest_profile() {
|
||||||
|
let start = Instant::now();
|
||||||
|
for (profile, source, target) in [
|
||||||
|
(
|
||||||
|
"xterm-256color",
|
||||||
|
b"\x1bOH".as_slice(),
|
||||||
|
b"\x1b[1~".as_slice(),
|
||||||
|
),
|
||||||
|
("alacritty", b"\x1bOF", b"\x1b[4~"),
|
||||||
|
("foot", b"\x1bOH", b"\x1b[1~"),
|
||||||
|
("linux", b"\x1b[[A", b"\x1bOP"),
|
||||||
|
("linux", b"\x1b[A", b"\x1bOA"),
|
||||||
|
] {
|
||||||
|
let mut translator = super::KeyTranslator::for_profile(Some(profile));
|
||||||
|
let mut translated = Vec::new();
|
||||||
|
for (index, byte) in source.iter().enumerate() {
|
||||||
|
translated.extend(translator.feed(
|
||||||
|
std::slice::from_ref(byte),
|
||||||
|
start + Duration::from_millis(u64::try_from(index).unwrap() * 5),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert_eq!(translated, target);
|
||||||
|
}
|
||||||
|
for profile in ["screen-256color", "tmux-256color"] {
|
||||||
|
let mut translator = super::KeyTranslator::for_profile(Some(profile));
|
||||||
|
assert_eq!(translator.feed(b"\x1bOA", start), b"\x1bOA");
|
||||||
|
}
|
||||||
|
let mut translator = super::KeyTranslator::for_profile(Some("linux"));
|
||||||
|
assert_eq!(
|
||||||
|
translator.feed(b"\x1b[200~paste\x1b[201~", start),
|
||||||
|
b"\x1b[200~paste\x1b[201~"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
fn signal_scope_restores_mask_and_default_termination() {
|
fn signal_scope_restores_mask_and_default_termination() {
|
||||||
|
|
|
||||||
123
crates/lazaret-client/src/terminal/prompt.rs
Normal file
123
crates/lazaret-client/src/terminal/prompt.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use lazaret_core::records::{DeveloperDecisionKind, TerminalResize};
|
||||||
|
|
||||||
|
use super::{PROMPT_ESCAPE_INTERVAL, PromptOutcome};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(super) enum PromptChoice {
|
||||||
|
Approve,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) enum PromptInput {
|
||||||
|
Continue,
|
||||||
|
Decide(PromptChoice),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct PromptInputDecoder {
|
||||||
|
pub(super) choice: PromptChoice,
|
||||||
|
escape: Vec<u8>,
|
||||||
|
escape_deadline: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromptInputDecoder {
|
||||||
|
pub(super) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
choice: PromptChoice::Deny,
|
||||||
|
escape: Vec::new(),
|
||||||
|
escape_deadline: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn feed(&mut self, bytes: &[u8], now: Instant) -> PromptInput {
|
||||||
|
if self.escape_deadline.is_some_and(|deadline| now >= deadline) {
|
||||||
|
self.clear_escape();
|
||||||
|
return PromptInput::Decide(PromptChoice::Deny);
|
||||||
|
}
|
||||||
|
for &byte in bytes {
|
||||||
|
if !self.escape.is_empty() || byte == 0x1b {
|
||||||
|
self.escape.push(byte);
|
||||||
|
self.escape_deadline = Some(now + PROMPT_ESCAPE_INTERVAL);
|
||||||
|
match self.escape.as_slice() {
|
||||||
|
b"\x1b[D" | b"\x1bOD" => {
|
||||||
|
self.choice = PromptChoice::Approve;
|
||||||
|
self.clear_escape();
|
||||||
|
}
|
||||||
|
b"\x1b[C" | b"\x1bOC" => {
|
||||||
|
self.choice = PromptChoice::Deny;
|
||||||
|
self.clear_escape();
|
||||||
|
}
|
||||||
|
sequence if supported_prompt_key_prefix(sequence) => {}
|
||||||
|
_ => return PromptInput::Decide(PromptChoice::Deny),
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match byte {
|
||||||
|
b'y' | b'Y' => return PromptInput::Decide(PromptChoice::Approve),
|
||||||
|
b'n' | b'N' | 0x03 | 0x04 | 0x1a => {
|
||||||
|
return PromptInput::Decide(PromptChoice::Deny);
|
||||||
|
}
|
||||||
|
b'\r' | b'\n' => return PromptInput::Decide(self.choice),
|
||||||
|
_ => return PromptInput::Decide(PromptChoice::Deny),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PromptInput::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn expire(&mut self, now: Instant) -> PromptInput {
|
||||||
|
if self.escape_deadline.is_some_and(|deadline| now >= deadline) {
|
||||||
|
self.clear_escape();
|
||||||
|
PromptInput::Decide(PromptChoice::Deny)
|
||||||
|
} else {
|
||||||
|
PromptInput::Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_escape(&mut self) {
|
||||||
|
self.escape.clear();
|
||||||
|
self.escape_deadline = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_prompt_key_prefix(bytes: &[u8]) -> bool {
|
||||||
|
[b"\x1b[D".as_slice(), b"\x1b[C", b"\x1bOD", b"\x1bOC"]
|
||||||
|
.iter()
|
||||||
|
.any(|sequence| sequence.starts_with(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn prompt_outcome(
|
||||||
|
choice: PromptChoice,
|
||||||
|
resumed_size: Option<TerminalResize>,
|
||||||
|
) -> PromptOutcome {
|
||||||
|
PromptOutcome {
|
||||||
|
decision: match choice {
|
||||||
|
PromptChoice::Approve => DeveloperDecisionKind::Approve,
|
||||||
|
PromptChoice::Deny => DeveloperDecisionKind::Deny,
|
||||||
|
},
|
||||||
|
resumed_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn display_argv(argv: &[Vec<u8>]) -> String {
|
||||||
|
let arguments = argv
|
||||||
|
.iter()
|
||||||
|
.map(|argument| format!("\"{}\"", display_bytes(argument)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
format!("[{arguments}]")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn display_bytes(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().fold(String::new(), |mut output, &byte| {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
match byte {
|
||||||
|
b'\\' => output.push_str("\\\\"),
|
||||||
|
b'\"' => output.push_str("\\\""),
|
||||||
|
0x20..=0x7e => output.push(char::from(byte)),
|
||||||
|
_ => write!(output, "\\x{byte:02x}").expect("writing to a String cannot fail"),
|
||||||
|
}
|
||||||
|
output
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ path = "tests/recovery-process.rs"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64.workspace = true
|
||||||
ed25519-dalek.workspace = true
|
ed25519-dalek.workspace = true
|
||||||
getrandom.workspace = true
|
getrandom.workspace = true
|
||||||
indicatif.workspace = true
|
indicatif.workspace = true
|
||||||
|
|
@ -42,6 +43,7 @@ serde.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
vt100.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "=3.27.0"
|
tempfile = "=3.27.0"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
HgIjACsADwBpARYFdG11eC0yNTZjb2xvcnx0bXV4IHdpdGggMjU2IGNvbG9ycwAAAQAAAQAAAAEBAAAAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAABUAAAAAgAAAAYAAAA/////////////////////////////////////////////////////wABAAAAAAEAAAAEAAYACAAZAB4AJQApAC0A//84AEkASwBPAFYA//9YAGQA//9oAGsAcQB1AHkA//9/AIEAhgCLAP//lACZAJ4A//+jAKgArQD//7IAtAC5AP//wgDHAM0A0wD//9YA///YAP///////9wA///gAP///////+IA///nAP//////////6wDvAPUA+QD9AAEBBwENARMBGQEfASQB//8pAf//LQEyATcBOwFCAf//SQFNAVUB//////////////////////////////////9dAf//YAFpAXIBewGEAY0BlgGfAagBsQH/////////////////////ugH/////ywHOAdkB3AHeAeEBOgL//z0CPwL//////////////////////////0QC//+FAv///////////////4kC/////////////////////5AC/////////////////////////////////////////////////////////////////////5UC/////5wC//////////+jAqoCsQL/////uAL//78C////////xgL/////////////zQLTAtkC4ALnAu4C9QL9AgUDDQMVAx0DJQMtAzUDPANDA0oDUQNZA2EDaQNxA3kDgQOJA5EDmAOfA6YDrQO1A70DxQPNA9UD3QPlA+0D9AP7AwIECQQRBBkEIQQpBDEEOQRBBEkEUARXBF4E/////////////////////////////////////////////////////////////2MEbgRzBHsEfwT//////////////////////////////////4gE////////////////////////jQT///////////////////////////////////////////////////////////////////////////////////////+TBP///////5cE1gQbW1oABwANABtbJWklcDElZDslcDIlZHIAG1sz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
|
||||||
|
|
@ -9,6 +9,7 @@ use std::os::unix::fs::{FileExt, PermissionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration};
|
use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration};
|
||||||
use lazaret_core::identity::{
|
use lazaret_core::identity::{
|
||||||
BundleDigest, ContainerId, HostPath, MountIdentity, RunscBuildId, RunscDigest,
|
BundleDigest, ContainerId, HostPath, MountIdentity, RunscBuildId, RunscDigest,
|
||||||
|
|
@ -25,8 +26,8 @@ use lazaret_linux::process::{
|
||||||
};
|
};
|
||||||
use lazaret_linux::user_namespace::UserNamespaceMap;
|
use lazaret_linux::user_namespace::UserNamespaceMap;
|
||||||
use lazaret_runtime_supervisor::sandbox::{
|
use lazaret_runtime_supervisor::sandbox::{
|
||||||
RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
|
HostTerminal, RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
|
||||||
build_sandbox_bundle_with_workspace,
|
build_sandbox_bundle_with_workspace, stage_host_terminal,
|
||||||
};
|
};
|
||||||
use lazaret_runtime_supervisor::supervision::{
|
use lazaret_runtime_supervisor::supervision::{
|
||||||
RuntimeGitRoutingAuthority, RuntimeGitRoutingLaunch, RuntimeNetworkLaunch,
|
RuntimeGitRoutingAuthority, RuntimeGitRoutingLaunch, RuntimeNetworkLaunch,
|
||||||
|
|
@ -50,6 +51,8 @@ use crate::terminal::RunscInteractiveSessionExecutor;
|
||||||
use crate::waiter::{BootstrapWaitOutcome, BootstrapWaiterFace};
|
use crate::waiter::{BootstrapWaitOutcome, BootstrapWaiterFace};
|
||||||
|
|
||||||
const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
|
const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
|
||||||
|
const CONTROLLED_TERM: &str = "tmux-256color";
|
||||||
|
const CONTROLLED_TERMINFO: &str = include_str!("../assets/tmux-256color.base64");
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ColdStartError(String);
|
pub struct ColdStartError(String);
|
||||||
|
|
@ -199,13 +202,21 @@ impl<'a> ColdStart<'a> {
|
||||||
.map_err(|error| failure(format!("network policy: {error}")))?;
|
.map_err(|error| failure(format!("network policy: {error}")))?;
|
||||||
runtime_files::replace_protected_runtime_files(&state_anchor_layout, &network_policy)
|
runtime_files::replace_protected_runtime_files(&state_anchor_layout, &network_policy)
|
||||||
.map_err(|error| failure(format!("protected runtime files: {error}")))?;
|
.map_err(|error| failure(format!("protected runtime files: {error}")))?;
|
||||||
|
let terminal_bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(CONTROLLED_TERMINFO.trim())
|
||||||
|
.map_err(|error| failure(format!("controlled terminfo decoding: {error}")))?;
|
||||||
|
let terminal = HostTerminal::from_compiled_entry(CONTROLLED_TERM, None, terminal_bytes)
|
||||||
|
.and_then(|terminal| {
|
||||||
|
stage_host_terminal(inputs.configuration, inputs.container_id, terminal)
|
||||||
|
})
|
||||||
|
.map_err(|error| failure(format!("controlled terminfo staging: {error}")))?;
|
||||||
let request = SandboxBuildRequest {
|
let request = SandboxBuildRequest {
|
||||||
configuration: inputs.configuration,
|
configuration: inputs.configuration,
|
||||||
runsc_program: inputs.runsc_program,
|
runsc_program: inputs.runsc_program,
|
||||||
container_id: inputs.container_id,
|
container_id: inputs.container_id,
|
||||||
expected_network_namespace,
|
expected_network_namespace,
|
||||||
root_process: inputs.root_process,
|
root_process: inputs.root_process,
|
||||||
terminal: None,
|
terminal: Some(terminal),
|
||||||
prepared_root: inputs.prepared_artifact_label.to_owned(),
|
prepared_root: inputs.prepared_artifact_label.to_owned(),
|
||||||
};
|
};
|
||||||
let sandbox_bundle = match &git_routing {
|
let sandbox_bundle = match &git_routing {
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,7 @@ impl TerminalDomainStore for HostCoordinatorDomainStore {
|
||||||
|
|
||||||
mod engine;
|
mod engine;
|
||||||
mod interactive;
|
mod interactive;
|
||||||
|
mod retained;
|
||||||
mod runsc;
|
mod runsc;
|
||||||
|
|
||||||
use engine::release_physical;
|
use engine::release_physical;
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,11 @@ impl SessionTerminalEngine {
|
||||||
process,
|
process,
|
||||||
releasing: false,
|
releasing: false,
|
||||||
pending_input: VecDeque::new(),
|
pending_input: VecDeque::new(),
|
||||||
|
pending_render: VecDeque::new(),
|
||||||
|
retained: super::retained::RetainedTerminal::new(
|
||||||
|
request.initial_size.rows.get(),
|
||||||
|
request.initial_size.columns.get(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -204,6 +209,10 @@ impl SessionTerminalEngine {
|
||||||
}
|
}
|
||||||
SessionTerminalEventV2::Resize(resize) => {
|
SessionTerminalEventV2::Resize(resize) => {
|
||||||
terminal.process.resize(resize)?;
|
terminal.process.resize(resize)?;
|
||||||
|
terminal
|
||||||
|
.retained
|
||||||
|
.resize(resize.rows.get(), resize.columns.get());
|
||||||
|
terminal.pending_render.clear();
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
SessionTerminalEventV2::Break(_) => {
|
SessionTerminalEventV2::Break(_) => {
|
||||||
|
|
@ -236,6 +245,12 @@ 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() {
|
||||||
|
terminal.pending_render.extend(restoration);
|
||||||
|
}
|
||||||
|
if let Some(message) = take_rendered_output(terminal, session_id) {
|
||||||
|
return Ok(vec![message]);
|
||||||
|
}
|
||||||
flush_input(terminal)?;
|
flush_input(terminal)?;
|
||||||
let mut bytes = [0_u8; MAX_OUTPUT_BYTES];
|
let mut bytes = [0_u8; MAX_OUTPUT_BYTES];
|
||||||
match terminal.process.read_output(&mut bytes) {
|
match terminal.process.read_output(&mut bytes) {
|
||||||
|
|
@ -246,10 +261,18 @@ impl SessionTerminalEngine {
|
||||||
true,
|
true,
|
||||||
GuestReleaseAuthority::Validate,
|
GuestReleaseAuthority::Validate,
|
||||||
),
|
),
|
||||||
Ok(length) => Ok(vec![SessionTerminalMessage::Output(TerminalOutput {
|
Ok(length) => {
|
||||||
session_id,
|
let (rendered, replies) = terminal.retained.process(&bytes[..length]);
|
||||||
bytes: bytes[..length].to_vec(),
|
if terminal.pending_input.len() + replies.len() > MAX_INPUT_QUEUE_BYTES {
|
||||||
})]),
|
return Err(TerminalError::Backpressure);
|
||||||
|
}
|
||||||
|
terminal.pending_input.extend(replies);
|
||||||
|
flush_input(terminal)?;
|
||||||
|
terminal.pending_render.extend(rendered);
|
||||||
|
Ok(take_rendered_output(terminal, session_id)
|
||||||
|
.into_iter()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(Vec::new()),
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(Vec::new()),
|
||||||
Err(_) => self.close(
|
Err(_) => self.close(
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -345,7 +368,12 @@ impl SessionTerminalEngine {
|
||||||
Err(with_cleanup(error, cleanup))
|
Err(with_cleanup(error, cleanup))
|
||||||
}
|
}
|
||||||
Ok(route) => {
|
Ok(route) => {
|
||||||
self.active_mut(session_id, generation_id)?.route = route;
|
let terminal = self.active_mut(session_id, generation_id)?;
|
||||||
|
if terminal.route == TerminalRoute::Sandbox && route != TerminalRoute::Sandbox {
|
||||||
|
terminal.pending_render.clear();
|
||||||
|
terminal.retained.obscure_client();
|
||||||
|
}
|
||||||
|
terminal.route = route;
|
||||||
Ok(route)
|
Ok(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -403,6 +431,7 @@ impl SessionTerminalEngine {
|
||||||
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
||||||
.is_ok()));
|
.is_ok()));
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
|
let presentation_route = terminal.route;
|
||||||
terminal.releasing = true;
|
terminal.releasing = true;
|
||||||
terminal.route = TerminalRoute::Detached;
|
terminal.route = TerminalRoute::Detached;
|
||||||
let release =
|
let release =
|
||||||
|
|
@ -418,12 +447,33 @@ impl SessionTerminalEngine {
|
||||||
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
||||||
.is_ok()
|
.is_ok()
|
||||||
};
|
};
|
||||||
let (mut output, exit, guest_absent) = release_physical(
|
let (output, exit, guest_absent) = release_physical(
|
||||||
terminal.process.as_mut(),
|
terminal.process.as_mut(),
|
||||||
session_id,
|
session_id,
|
||||||
connected,
|
connected,
|
||||||
&mut authorize_guest,
|
&mut authorize_guest,
|
||||||
)?;
|
)?;
|
||||||
|
let mut rendered = terminal.pending_render.drain(..).collect::<Vec<_>>();
|
||||||
|
if connected && presentation_route != TerminalRoute::Sandbox {
|
||||||
|
terminal.retained.obscure_client();
|
||||||
|
rendered.extend(terminal.retained.take_restoration().unwrap_or_default());
|
||||||
|
}
|
||||||
|
for message in output {
|
||||||
|
if let SessionTerminalMessage::Output(output) = message {
|
||||||
|
let (bytes, _) = terminal.retained.process(&output.bytes);
|
||||||
|
rendered.extend(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rendered.truncate(MAX_DRAIN_BYTES);
|
||||||
|
let mut output = rendered
|
||||||
|
.chunks(MAX_OUTPUT_BYTES)
|
||||||
|
.map(|bytes| {
|
||||||
|
SessionTerminalMessage::Output(TerminalOutput {
|
||||||
|
session_id,
|
||||||
|
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)?;
|
||||||
|
|
@ -442,6 +492,19 @@ impl SessionTerminalEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn take_rendered_output(
|
||||||
|
terminal: &mut ActiveTerminal,
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Option<SessionTerminalMessage> {
|
||||||
|
let length = terminal.pending_render.len().min(MAX_OUTPUT_BYTES);
|
||||||
|
(length != 0).then(|| {
|
||||||
|
SessionTerminalMessage::Output(TerminalOutput {
|
||||||
|
session_id,
|
||||||
|
bytes: terminal.pending_render.drain(..length).collect(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn release_physical(
|
pub(super) fn release_physical(
|
||||||
process: &mut dyn SessionTerminalProcess,
|
process: &mut dyn SessionTerminalProcess,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use super::retained::RetainedTerminal;
|
||||||
use super::{
|
use super::{
|
||||||
Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue,
|
Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue,
|
||||||
GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder,
|
GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder,
|
||||||
|
|
@ -18,6 +19,8 @@ pub(super) struct ActiveTerminal {
|
||||||
pub(super) process: Box<dyn SessionTerminalProcess>,
|
pub(super) process: Box<dyn SessionTerminalProcess>,
|
||||||
pub(super) releasing: bool,
|
pub(super) releasing: bool,
|
||||||
pub(super) pending_input: VecDeque<u8>,
|
pub(super) pending_input: VecDeque<u8>,
|
||||||
|
pub(super) pending_render: VecDeque<u8>,
|
||||||
|
pub(super) retained: RetainedTerminal,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SessionTerminalEngine {
|
pub struct SessionTerminalEngine {
|
||||||
|
|
|
||||||
177
crates/lazaret-host-coordinator/src/terminal/retained.rs
Normal file
177
crates/lazaret-host-coordinator/src/terminal/retained.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
const SYNCHRONIZED_OUTPUT_START: &[u8] = b"\x1b[?2026h";
|
||||||
|
const SYNCHRONIZED_OUTPUT_END: &[u8] = b"\x1b[?2026l";
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TerminalCallbacks {
|
||||||
|
replies: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl vt100::Callbacks for TerminalCallbacks {
|
||||||
|
fn unhandled_csi(
|
||||||
|
&mut self,
|
||||||
|
screen: &mut vt100::Screen,
|
||||||
|
intermediate_one: Option<u8>,
|
||||||
|
intermediate_two: Option<u8>,
|
||||||
|
parameters: &[&[u16]],
|
||||||
|
character: char,
|
||||||
|
) {
|
||||||
|
let first = parameters
|
||||||
|
.first()
|
||||||
|
.and_then(|parameter| parameter.first())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0);
|
||||||
|
if intermediate_one.is_none()
|
||||||
|
&& intermediate_two.is_none()
|
||||||
|
&& character == 'n'
|
||||||
|
&& first == 6
|
||||||
|
{
|
||||||
|
let (row, column) = screen.cursor_position();
|
||||||
|
self.replies
|
||||||
|
.extend_from_slice(format!("\x1b[{};{}R", row + 1, column + 1).as_bytes());
|
||||||
|
} else if intermediate_one.is_none()
|
||||||
|
&& intermediate_two.is_none()
|
||||||
|
&& character == 'c'
|
||||||
|
&& first == 0
|
||||||
|
{
|
||||||
|
self.replies.extend_from_slice(b"\x1b[?1;2c");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct RetainedTerminal {
|
||||||
|
parser: vt100::Parser<TerminalCallbacks>,
|
||||||
|
client_initialized: bool,
|
||||||
|
restore_pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetainedTerminal {
|
||||||
|
pub(super) fn new(rows: u16, columns: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
parser: vt100::Parser::new_with_callbacks(
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
0,
|
||||||
|
TerminalCallbacks::default(),
|
||||||
|
),
|
||||||
|
client_initialized: false,
|
||||||
|
restore_pending: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn process(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let previous = self.parser.screen().clone();
|
||||||
|
self.parser.process(bytes);
|
||||||
|
let rendered = if self.client_initialized {
|
||||||
|
self.parser.screen().state_diff(&previous)
|
||||||
|
} else {
|
||||||
|
self.client_initialized = true;
|
||||||
|
self.redraw()
|
||||||
|
};
|
||||||
|
let replies = std::mem::take(&mut self.parser.callbacks_mut().replies);
|
||||||
|
(rendered, replies)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resize(&mut self, rows: u16, columns: u16) {
|
||||||
|
self.parser.screen_mut().set_size(rows, columns);
|
||||||
|
if self.client_initialized {
|
||||||
|
self.restore_pending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn obscure_client(&mut self) {
|
||||||
|
self.restore_pending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn take_restoration(&mut self) -> Option<Vec<u8>> {
|
||||||
|
if !self.restore_pending {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.restore_pending = false;
|
||||||
|
self.client_initialized = true;
|
||||||
|
Some(self.redraw())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redraw(&self) -> Vec<u8> {
|
||||||
|
let mut rendered = Vec::new();
|
||||||
|
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_START);
|
||||||
|
rendered.extend_from_slice(b"\x1b[2J\x1b[H");
|
||||||
|
rendered.extend(self.parser.screen().state_formatted());
|
||||||
|
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_END);
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::RetainedTerminal;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_and_rendering_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 expected = retained_state(&[bytes]);
|
||||||
|
for split in 0..=bytes.len() {
|
||||||
|
assert_eq!(
|
||||||
|
retained_state(&[&bytes[..split], &bytes[split..]]),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restoration_reproduces_normal_and_alternate_screen_state() {
|
||||||
|
for bytes in [
|
||||||
|
b"normal\x1b[3;4H\x1b[?25l".as_slice(),
|
||||||
|
b"normal\x1b[?1049h\x1b[2J\x1b[Halternate\x1b[4;5H".as_slice(),
|
||||||
|
] {
|
||||||
|
let mut terminal = RetainedTerminal::new(8, 32);
|
||||||
|
let (initial, _) = terminal.process(bytes);
|
||||||
|
terminal.obscure_client();
|
||||||
|
let restored = terminal.take_restoration().unwrap();
|
||||||
|
assert_eq!(rendered_state(&initial), rendered_state(&restored));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_replies_derive_from_retained_cursor() {
|
||||||
|
let mut terminal = RetainedTerminal::new(24, 80);
|
||||||
|
let (_, replies) = terminal.process(b"\x1b[7;11H\x1b[6n\x1b[c");
|
||||||
|
assert_eq!(replies, b"\x1b[7;11R\x1b[?1;2c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
terminal.process(chunk);
|
||||||
|
}
|
||||||
|
terminal.obscure_client();
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,9 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
|
||||||
.argument(format!("PATH={}", crate::sandbox_init::DEFAULT_AGENT_PATH));
|
.argument(format!("PATH={}", crate::sandbox_init::DEFAULT_AGENT_PATH));
|
||||||
}
|
}
|
||||||
for (name, value) in &request.environment {
|
for (name, value) in &request.environment {
|
||||||
|
if matches!(name.as_str(), "TERM" | "TERMINFO" | "TERMINFO_DIRS") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut assignment = OsString::from(name);
|
let mut assignment = OsString::from(name);
|
||||||
assignment.push("=");
|
assignment.push("=");
|
||||||
assignment.push(value.expose());
|
assignment.push(value.expose());
|
||||||
|
|
@ -70,6 +73,10 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
|
||||||
request.internal_pid_file.display()
|
request.internal_pid_file.display()
|
||||||
))
|
))
|
||||||
.argument("-env")
|
.argument("-env")
|
||||||
|
.argument("TERM=tmux-256color")
|
||||||
|
.argument("-env")
|
||||||
|
.argument("TERMINFO_DIRS=/run/lazaret/terminfo:")
|
||||||
|
.argument("-env")
|
||||||
.argument(format!(
|
.argument(format!(
|
||||||
"LAZARET_SESSION_ID={}",
|
"LAZARET_SESSION_ID={}",
|
||||||
hex_lower(request.session_id.as_bytes())
|
hex_lower(request.session_id.as_bytes())
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ fn insert_attached_sessions(store: &HostCoordinatorDomainStore, session_ids: &[S
|
||||||
|
|
||||||
struct MockState {
|
struct MockState {
|
||||||
output: VecDeque<Vec<u8>>,
|
output: VecDeque<Vec<u8>>,
|
||||||
|
input: Vec<u8>,
|
||||||
log: Arc<Mutex<Vec<String>>>,
|
log: Arc<Mutex<Vec<String>>>,
|
||||||
guest: MockGuestState,
|
guest: MockGuestState,
|
||||||
resources: MockResourceState,
|
resources: MockResourceState,
|
||||||
|
|
@ -332,9 +333,9 @@ impl TerminalDomainStore for FailingRunningStore {
|
||||||
|
|
||||||
impl SessionTerminalProcess for MockProcess {
|
impl SessionTerminalProcess for MockProcess {
|
||||||
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||||
self.state
|
let state = &mut *self.state.lock().unwrap();
|
||||||
.lock()
|
state.input.extend_from_slice(bytes);
|
||||||
.unwrap()
|
state
|
||||||
.log
|
.log
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -499,6 +500,7 @@ impl SessionTerminalFactory for MockFactory {
|
||||||
recorder.put(running.clone())?;
|
recorder.put(running.clone())?;
|
||||||
let state = Arc::new(Mutex::new(MockState {
|
let state = Arc::new(Mutex::new(MockState {
|
||||||
output: VecDeque::new(),
|
output: VecDeque::new(),
|
||||||
|
input: Vec::new(),
|
||||||
log: Arc::clone(&self.log),
|
log: Arc::clone(&self.log),
|
||||||
guest: MockGuestState {
|
guest: MockGuestState {
|
||||||
group_present: true,
|
group_present: true,
|
||||||
|
|
|
||||||
|
|
@ -570,6 +570,7 @@ fn running_store_failures_release_every_live_partial_handle() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let state = Arc::new(Mutex::new(MockState {
|
let state = Arc::new(Mutex::new(MockState {
|
||||||
output: VecDeque::new(),
|
output: VecDeque::new(),
|
||||||
|
input: Vec::new(),
|
||||||
log: Arc::new(Mutex::new(Vec::new())),
|
log: Arc::new(Mutex::new(Vec::new())),
|
||||||
guest: MockGuestState {
|
guest: MockGuestState {
|
||||||
group_present: true,
|
group_present: true,
|
||||||
|
|
|
||||||
|
|
@ -24,26 +24,79 @@ fn merged_output_is_ordered_and_prompt_route_isolated_by_session() {
|
||||||
.set_route(first, GenerationId::new(7), TerminalRoute::Prompt)
|
.set_route(first, GenerationId::new(7), TerminalRoute::Prompt)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(engine.poll(first, GenerationId::new(7)).unwrap().is_empty());
|
assert!(engine.poll(first, GenerationId::new(7)).unwrap().is_empty());
|
||||||
assert_eq!(
|
let second_output = output_bytes(engine.poll(second, GenerationId::new(7)).unwrap());
|
||||||
engine.poll(second, GenerationId::new(7)).unwrap(),
|
assert_eq!(screen_contents(&second_output), "other");
|
||||||
vec![SessionTerminalMessage::Output(TerminalOutput {
|
|
||||||
session_id: second,
|
|
||||||
bytes: b"other".to_vec(),
|
|
||||||
})]
|
|
||||||
);
|
|
||||||
fixture.set_session_phase(first, SessionPhase::Attached);
|
fixture.set_session_phase(first, SessionPhase::Attached);
|
||||||
engine
|
engine
|
||||||
.set_route(first, GenerationId::new(7), TerminalRoute::Sandbox)
|
.set_route(first, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut merged = Vec::new();
|
let mut rendered = Vec::new();
|
||||||
for _ in 0..2 {
|
for _ in 0..3 {
|
||||||
let messages = engine.poll(first, GenerationId::new(7)).unwrap();
|
let messages = engine.poll(first, GenerationId::new(7)).unwrap();
|
||||||
let [SessionTerminalMessage::Output(output)] = messages.as_slice() else {
|
let [SessionTerminalMessage::Output(output)] = messages.as_slice() else {
|
||||||
panic!("expected one merged output message");
|
panic!("expected one merged output message");
|
||||||
};
|
};
|
||||||
merged.extend_from_slice(&output.bytes);
|
rendered.extend_from_slice(&output.bytes);
|
||||||
}
|
}
|
||||||
assert_eq!(merged, b"onetwo");
|
assert_eq!(screen_contents(&rendered), "onetwo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_and_host_process_presentations_restore_the_retained_screen() {
|
||||||
|
let session = id(6);
|
||||||
|
let (fixture, factory, mut engine) = setup(&[session]);
|
||||||
|
engine.acquire(&acquisition(session)).unwrap();
|
||||||
|
factory.state(0).lock().unwrap().output.push_back(
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
let initial = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
||||||
|
let mut client = vt100::Parser::new(24, 80, 0);
|
||||||
|
client.process(&initial);
|
||||||
|
let expected = client.screen().state_formatted();
|
||||||
|
|
||||||
|
fixture.set_session_phase(session, SessionPhase::Prompting);
|
||||||
|
engine
|
||||||
|
.set_route(session, GenerationId::new(7), TerminalRoute::Prompt)
|
||||||
|
.unwrap();
|
||||||
|
client.process(b"\x1b[2J\x1b[HPROMPT\x1b[24;80H");
|
||||||
|
fixture.set_session_phase(session, SessionPhase::HostExecuting);
|
||||||
|
engine
|
||||||
|
.set_route(session, GenerationId::new(7), TerminalRoute::HostProcess)
|
||||||
|
.unwrap();
|
||||||
|
client.process(b"\x1b[2J\x1b[HHOST PROCESS\x1b[?25h");
|
||||||
|
assert!(
|
||||||
|
engine
|
||||||
|
.poll(session, GenerationId::new(7))
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.set_session_phase(session, SessionPhase::Attached);
|
||||||
|
engine
|
||||||
|
.set_route(session, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||||
|
.unwrap();
|
||||||
|
let restored = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
||||||
|
client.process(&restored);
|
||||||
|
assert_eq!(client.screen().state_formatted(), expected);
|
||||||
|
assert!(factory.state(0).lock().unwrap().input.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_queries_reply_to_the_guest_from_retained_state() {
|
||||||
|
let session = id(6);
|
||||||
|
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||||
|
engine.acquire(&acquisition(session)).unwrap();
|
||||||
|
factory
|
||||||
|
.state(0)
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.output
|
||||||
|
.push_back(b"\x1b[7;11H\x1b[6n\x1b[c".to_vec());
|
||||||
|
engine.poll(session, GenerationId::new(7)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
factory.state(0).lock().unwrap().input,
|
||||||
|
b"\x1b[7;11R\x1b[?1;2c"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -66,14 +119,16 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let bytes = messages
|
assert!(messages.iter().all(|message| match message {
|
||||||
.iter()
|
SessionTerminalMessage::Output(output) => output.bytes.len() <= MAX_OUTPUT_BYTES,
|
||||||
.filter_map(|message| match message {
|
SessionTerminalMessage::Exit(_) => true,
|
||||||
SessionTerminalMessage::Output(output) => Some(output.bytes.len()),
|
}));
|
||||||
SessionTerminalMessage::Exit(_) => None,
|
assert_eq!(factory.state(0).lock().unwrap().output.len(), 6);
|
||||||
})
|
assert!(
|
||||||
.sum::<usize>();
|
screen_contents(&output_bytes(messages.clone()))
|
||||||
assert_eq!(bytes, MAX_DRAIN_BYTES);
|
.chars()
|
||||||
|
.all(|character| character == 'x' || character == '\n')
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(messages.last(), Some(SessionTerminalMessage::Exit(exit)) if exit.exit_code() == Some(17))
|
matches!(messages.last(), Some(SessionTerminalMessage::Exit(exit)) if exit.exit_code() == Some(17))
|
||||||
);
|
);
|
||||||
|
|
@ -104,6 +159,23 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn output_bytes(messages: Vec<SessionTerminalMessage>) -> Vec<u8> {
|
||||||
|
messages
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|message| match message {
|
||||||
|
SessionTerminalMessage::Output(output) => Some(output.bytes),
|
||||||
|
SessionTerminalMessage::Exit(_) => None,
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_contents(bytes: &[u8]) -> String {
|
||||||
|
let mut parser = vt100::Parser::new(24, 80, 0);
|
||||||
|
parser.process(bytes);
|
||||||
|
parser.screen().contents()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disconnect_component_loss_and_hard_hc_loss_never_reattach() {
|
fn disconnect_component_loss_and_hard_hc_loss_never_reattach() {
|
||||||
for loss in [
|
for loss in [
|
||||||
|
|
|
||||||
|
|
@ -344,13 +344,26 @@ fn test_user_namespace_map() -> UserNamespaceMap {
|
||||||
fn assert_private_artifacts(bundle: &Path, control: &Path) {
|
fn assert_private_artifacts(bundle: &Path, control: &Path) {
|
||||||
let config = bundle.join("config.json");
|
let config = bundle.join("config.json");
|
||||||
let escalation_client = control.join("bin/lazaret-escalate");
|
let escalation_client = control.join("bin/lazaret-escalate");
|
||||||
|
let terminfo = control.join("terminfo/t/tmux-256color");
|
||||||
assert!(config.is_file());
|
assert!(config.is_file());
|
||||||
assert!(escalation_client.is_file());
|
assert!(escalation_client.is_file());
|
||||||
|
assert!(terminfo.is_file());
|
||||||
assert!(control.join("credentials").is_dir());
|
assert!(control.join("credentials").is_dir());
|
||||||
assert_eq!(mode(bundle), 0o700);
|
assert_eq!(mode(bundle), 0o700);
|
||||||
assert_eq!(mode(control), 0o700);
|
assert_eq!(mode(control), 0o700);
|
||||||
assert_eq!(mode(&config), 0o600);
|
assert_eq!(mode(&config), 0o600);
|
||||||
assert_eq!(mode(&escalation_client), 0o700);
|
assert_eq!(mode(&escalation_client), 0o700);
|
||||||
|
assert_eq!(mode(&terminfo), 0o400);
|
||||||
|
let specification: serde_json::Value =
|
||||||
|
serde_json::from_slice(&fs::read(config).unwrap()).unwrap();
|
||||||
|
let mount = specification["mounts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|mount| mount["destination"] == "/run/lazaret/terminfo/t/tmux-256color")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(mount["source"], terminfo.display().to_string());
|
||||||
|
assert!(mount["options"].as_array().unwrap().contains(&"ro".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mode(path: &Path) -> u32 {
|
fn mode(path: &Path) -> u32 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue