Implement PTY-native session terminal control
This commit is contained in:
parent
058e58e816
commit
8cef037fa3
7 changed files with 1488 additions and 382 deletions
|
|
@ -3,12 +3,41 @@ use super::{
|
|||
HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder, Instant, MAX_DRAIN_BYTES,
|
||||
MAX_EVENT_BYTES, MAX_INPUT_QUEUE_BYTES, MAX_OUTPUT_BYTES, POLL_INTERVAL,
|
||||
PersistedHostProcessOwnership, ProcessExit, PromptRouteCancellation, SIGNAL_GRACE, SessionExit,
|
||||
SessionId, SessionTerminalEngine, SessionTerminalEvent, SessionTerminalFactory,
|
||||
SessionId, SessionTerminalEngine, SessionTerminalEventV2, SessionTerminalFactory,
|
||||
SessionTerminalMessage, SessionTerminalProcess, SessionTerminalState, TerminalAcquisition,
|
||||
TerminalError, TerminalLoss, TerminalOutput, TerminalRoute, TerminalSignalKind, VecDeque,
|
||||
clean_terminal, copy_terminal_state, io, terminal_snapshot,
|
||||
TerminalError, TerminalLoss, TerminalOutput, TerminalRoute, VecDeque, clean_terminal,
|
||||
copy_terminal_state, io, terminal_snapshot,
|
||||
};
|
||||
|
||||
fn require_exec_absent<Open>(
|
||||
ownership: &PersistedHostProcessOwnership,
|
||||
open: &mut Open,
|
||||
) -> Result<(), TerminalError>
|
||||
where
|
||||
Open: FnMut(
|
||||
super::PidfsProcessIdentityV1,
|
||||
) -> io::Result<lazaret_linux::process::PidfsRecoveryMatch>,
|
||||
{
|
||||
let Ok(identity) = ownership.authoritative_pidfs() else {
|
||||
return Ok(());
|
||||
};
|
||||
match open(*identity) {
|
||||
Ok(lazaret_linux::process::PidfsRecoveryMatch::BootMismatch) => Ok(()),
|
||||
Ok(lazaret_linux::process::PidfsRecoveryMatch::Matched(process))
|
||||
if process.has_exited()? =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Ok(lazaret_linux::process::PidfsRecoveryMatch::Matched(_)) => {
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
}
|
||||
Err(error) if error.raw_os_error() == Some(rustix::io::Errno::SRCH.raw_os_error()) => {
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionTerminalEngine {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
|
|
@ -58,6 +87,56 @@ impl SessionTerminalEngine {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn clean_after_sandbox_absence<Open>(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
container_id: super::ContainerId,
|
||||
host_coordinator: super::PidfsProcessIdentityV1,
|
||||
mut open: Open,
|
||||
) -> Result<(), TerminalError>
|
||||
where
|
||||
Open: FnMut(
|
||||
super::PidfsProcessIdentityV1,
|
||||
) -> io::Result<lazaret_linux::process::PidfsRecoveryMatch>,
|
||||
{
|
||||
if self
|
||||
.active
|
||||
.values()
|
||||
.any(|terminal| terminal.generation_id == generation_id)
|
||||
{
|
||||
return Err(TerminalError::AuthorityMismatch);
|
||||
}
|
||||
let snapshot = self.recorder.store.snapshot()?;
|
||||
for terminal in snapshot
|
||||
.session_terminals
|
||||
.iter()
|
||||
.filter(|terminal| terminal.generation_id() == generation_id)
|
||||
{
|
||||
if terminal.state() == SessionTerminalState::Clean {
|
||||
continue;
|
||||
}
|
||||
let closed = snapshot.sessions.iter().any(|session| {
|
||||
session.generation_id == generation_id
|
||||
&& session.session_id == terminal.session_id()
|
||||
&& matches!(
|
||||
session.session_phase,
|
||||
super::SessionPhase::Closed | super::SessionPhase::Failed
|
||||
)
|
||||
});
|
||||
if terminal.state() != SessionTerminalState::ReleaseIntent
|
||||
|| terminal.container_id() != Some(container_id)
|
||||
|| terminal.host_coordinator().authoritative_pidfs().ok() != Some(&host_coordinator)
|
||||
|| !closed
|
||||
{
|
||||
return Err(TerminalError::AuthorityMismatch);
|
||||
}
|
||||
require_exec_absent(terminal.exec_client(), &mut open)?;
|
||||
self.recorder
|
||||
.clean(clean_terminal(generation_id, terminal.session_id())?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses stale, unknown, detached, or releasing Session Terminals.
|
||||
pub fn set_route(
|
||||
|
|
@ -83,15 +162,15 @@ impl SessionTerminalEngine {
|
|||
pub fn handle(
|
||||
&mut self,
|
||||
generation_id: GenerationId,
|
||||
event: SessionTerminalEvent,
|
||||
event: SessionTerminalEventV2,
|
||||
) -> Result<Vec<SessionTerminalMessage>, TerminalError> {
|
||||
let session_id = match &event {
|
||||
SessionTerminalEvent::Input(event) => event.session_id,
|
||||
SessionTerminalEvent::Resize(event) => event.session_id,
|
||||
SessionTerminalEvent::Signal(event) => event.session_id,
|
||||
SessionTerminalEvent::Close(event) => event.session_id,
|
||||
SessionTerminalEventV2::Input(event) => event.session_id,
|
||||
SessionTerminalEventV2::Resize(event) => event.session_id,
|
||||
SessionTerminalEventV2::Break(event) => event.session_id,
|
||||
SessionTerminalEventV2::Close(event) => event.session_id,
|
||||
};
|
||||
if matches!(event, SessionTerminalEvent::Close(_)) {
|
||||
if matches!(event, SessionTerminalEventV2::Close(_)) {
|
||||
return self.close(
|
||||
session_id,
|
||||
generation_id,
|
||||
|
|
@ -112,7 +191,7 @@ impl SessionTerminalEngine {
|
|||
return Err(TerminalError::InactiveRoute);
|
||||
}
|
||||
match event {
|
||||
SessionTerminalEvent::Input(input) => {
|
||||
SessionTerminalEventV2::Input(input) => {
|
||||
if input.bytes.len() > MAX_EVENT_BYTES {
|
||||
return Err(TerminalError::OversizedInput);
|
||||
}
|
||||
|
|
@ -123,20 +202,15 @@ impl SessionTerminalEngine {
|
|||
flush_input(terminal)?;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
SessionTerminalEvent::Resize(resize) => {
|
||||
SessionTerminalEventV2::Resize(resize) => {
|
||||
terminal.process.resize(resize)?;
|
||||
terminal.process.signal_group("WINCH")?;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
SessionTerminalEvent::Signal(signal) => {
|
||||
terminal.process.signal_group(match signal.signal {
|
||||
TerminalSignalKind::Interrupt => "INT",
|
||||
TerminalSignalKind::Quit => "QUIT",
|
||||
TerminalSignalKind::Suspend => "TSTP",
|
||||
})?;
|
||||
SessionTerminalEventV2::Break(_) => {
|
||||
terminal.process.send_break()?;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
SessionTerminalEvent::Close(_) => unreachable!(),
|
||||
SessionTerminalEventV2::Close(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
use super::{
|
||||
Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue,
|
||||
GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder,
|
||||
InteractiveControlSession, InteractiveSessionBatch, InteractiveSessionEvent,
|
||||
InteractiveSessionExecutor, InteractiveSessionMessage, InteractiveSessionRequest, Mutex,
|
||||
OsString, PathBuf, PidfsProcessIdentityV1, PromptRouteCancellation, ProtocolVersion,
|
||||
RunscSessionTerminalFactory, SessionId, SessionPromptTransport, SessionTerminal,
|
||||
SessionTerminalEvent, SessionTerminalFactory, SessionTerminalMessage, SessionTerminalProcess,
|
||||
TerminalAcquisition, TerminalError, TerminalLoss, TerminalRoute, VecDeque,
|
||||
InteractiveControlSession, InteractiveProtocolVersion, InteractiveSessionBatchV2,
|
||||
InteractiveSessionEventV2, InteractiveSessionExecutor, InteractiveSessionMessage,
|
||||
InteractiveSessionRequestV2, Mutex, OsString, PathBuf, PidfsProcessIdentityV1,
|
||||
PromptRouteCancellation, RunscSessionTerminalFactory, SessionId, SessionPromptTransport,
|
||||
SessionTerminal, SessionTerminalEventV2, SessionTerminalFactory, SessionTerminalMessage,
|
||||
SessionTerminalProcess, TerminalAcquisition, TerminalError, TerminalLoss, TerminalRoute,
|
||||
VecDeque,
|
||||
};
|
||||
|
||||
pub(super) struct ActiveTerminal {
|
||||
|
|
@ -91,9 +92,9 @@ impl RunscInteractiveSessionExecutor {
|
|||
impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
|
||||
fn start(
|
||||
&self,
|
||||
request: &InteractiveSessionRequest,
|
||||
request: &InteractiveSessionRequestV2,
|
||||
) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
|
||||
if request.protocol_version != ProtocolVersion::V1
|
||||
if request.protocol_version != InteractiveProtocolVersion::V2
|
||||
|| request.initial_size.session_id != request.session_id
|
||||
{
|
||||
return Err(ControlError::InvalidRequest);
|
||||
|
|
@ -113,22 +114,13 @@ impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
|
|||
hex_session(request.session_id.as_bytes())
|
||||
)),
|
||||
argv: request.argv.clone(),
|
||||
initial_size: request.initial_size,
|
||||
initial_modes: request.initial_modes.clone(),
|
||||
};
|
||||
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
|
||||
engine
|
||||
.acquire(&acquisition)
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
if let Err(error) = engine.handle(
|
||||
self.generation_id,
|
||||
SessionTerminalEvent::Resize(request.initial_size),
|
||||
) {
|
||||
let _ = engine.lose(
|
||||
request.session_id,
|
||||
self.generation_id,
|
||||
TerminalLoss::ClientDisconnected,
|
||||
);
|
||||
return Err(terminal_control_error(&error));
|
||||
}
|
||||
drop(engine);
|
||||
Ok(Box::new(RunscInteractiveControlSession {
|
||||
engine: Arc::clone(&self.engine),
|
||||
|
|
@ -137,6 +129,19 @@ impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
|
|||
session_id: request.session_id,
|
||||
}))
|
||||
}
|
||||
|
||||
fn clean_after_sandbox_absence(&self) -> Result<(), ControlError> {
|
||||
self.engine
|
||||
.lock()
|
||||
.map_err(|_| ControlError::LockPoisoned)?
|
||||
.clean_after_sandbox_absence(
|
||||
self.generation_id,
|
||||
self.container_id,
|
||||
self.host_coordinator,
|
||||
lazaret_linux::process::match_recovery_process,
|
||||
)
|
||||
.map_err(|error| terminal_control_error(&error))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RunscInteractiveControlSession {
|
||||
|
|
@ -149,11 +154,11 @@ pub(super) struct RunscInteractiveControlSession {
|
|||
impl InteractiveControlSession for RunscInteractiveControlSession {
|
||||
fn exchange(
|
||||
&mut self,
|
||||
event: InteractiveSessionEvent,
|
||||
) -> Result<InteractiveSessionBatch, ControlError> {
|
||||
event: InteractiveSessionEventV2,
|
||||
) -> Result<InteractiveSessionBatchV2, ControlError> {
|
||||
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
|
||||
let mut messages = match event {
|
||||
InteractiveSessionEvent::Terminal(SessionTerminalEvent::Resize(resize)) => {
|
||||
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Resize(resize)) => {
|
||||
if self
|
||||
.prompt_transport
|
||||
.retain_prompt_resize(resize)
|
||||
|
|
@ -162,17 +167,17 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
|
|||
Vec::new()
|
||||
} else {
|
||||
engine
|
||||
.handle(self.generation_id, SessionTerminalEvent::Resize(resize))
|
||||
.handle(self.generation_id, SessionTerminalEventV2::Resize(resize))
|
||||
.map_err(|error| terminal_control_error(&error))?
|
||||
}
|
||||
}
|
||||
InteractiveSessionEvent::Terminal(event) => engine
|
||||
InteractiveSessionEventV2::Terminal(event) => engine
|
||||
.handle(self.generation_id, event)
|
||||
.map_err(|error| terminal_control_error(&error))?,
|
||||
InteractiveSessionEvent::Poll { session_id } if session_id == self.session_id => {
|
||||
InteractiveSessionEventV2::Poll { session_id } if session_id == self.session_id => {
|
||||
Vec::new()
|
||||
}
|
||||
InteractiveSessionEvent::PromptDecision {
|
||||
InteractiveSessionEventV2::PromptDecision {
|
||||
session_id,
|
||||
signed_decision,
|
||||
} if session_id == self.session_id => {
|
||||
|
|
@ -190,7 +195,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
|
|||
.map_err(|error| terminal_control_error(&error))?;
|
||||
if let Some(resize) = decision.resumed_size {
|
||||
engine
|
||||
.handle(self.generation_id, SessionTerminalEvent::Resize(resize))
|
||||
.handle(self.generation_id, SessionTerminalEventV2::Resize(resize))
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
}
|
||||
}
|
||||
|
|
@ -199,8 +204,8 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
|
|||
.map_err(|error| terminal_control_error(&error))?;
|
||||
Vec::new()
|
||||
}
|
||||
InteractiveSessionEvent::PromptDecision { .. }
|
||||
| InteractiveSessionEvent::Poll { .. } => return Err(ControlError::InvalidRequest),
|
||||
InteractiveSessionEventV2::PromptDecision { .. }
|
||||
| InteractiveSessionEventV2::Poll { .. } => return Err(ControlError::InvalidRequest),
|
||||
};
|
||||
if let Some(prompt) = self
|
||||
.prompt_transport
|
||||
|
|
@ -210,7 +215,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
|
|||
engine
|
||||
.set_route(self.session_id, self.generation_id, TerminalRoute::Prompt)
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
return Ok(InteractiveSessionBatch {
|
||||
return Ok(InteractiveSessionBatchV2 {
|
||||
session_id: self.session_id,
|
||||
messages: vec![InteractiveSessionMessage::Prompt(prompt)],
|
||||
});
|
||||
|
|
@ -225,7 +230,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
|
|||
.map_err(|error| terminal_control_error(&error))?,
|
||||
);
|
||||
}
|
||||
Ok(InteractiveSessionBatch {
|
||||
Ok(InteractiveSessionBatchV2 {
|
||||
session_id: self.session_id,
|
||||
messages: messages
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
use super::{
|
||||
Arc, ChildStandardIo, DeviceId, File, FileDescriptor, GenerationId, GuestProcessGroupId,
|
||||
GuestProcessId, GuestProcessIdentity, GuestProcessStartTimeTicks,
|
||||
HostCoordinatorTerminalRecorder, IDENTITY_DEADLINE, InodeId, Instant, Mutex, OFlags,
|
||||
OpenptFlags, OsString, POLL_INTERVAL, PRIVATE_SESSION_EXEC_ROLE, PRIVATE_SESSION_IDENTITY_ROLE,
|
||||
PersistedHostProcessOwnership, PidfsProcessIdentityV1, ProcessExit, ProcessOwnershipCommitAck,
|
||||
ProcessOwnershipContext, PtyIdentity, RetainedProcess, SANDBOX_INIT_DESTINATION, SessionId,
|
||||
SessionTerminal, SessionTerminalFactory, SessionTerminalFields, SessionTerminalProcess,
|
||||
SessionTerminalState, StagedProcessOwnership, TargetCommand, TerminalAcquisition,
|
||||
TerminalError, TerminalResize, Winsize, child_broker, current_process_identity, fcntl_getfl,
|
||||
fcntl_setfl, fstat, grantpt, io, ioctl_tiocgptpeer, openpt, release_physical, tcsetwinsize,
|
||||
unlockpt,
|
||||
Arc, ChildStandardIo, ControlModes, DeviceId, File, FileDescriptor, GenerationId,
|
||||
GuestProcessGroupId, GuestProcessId, GuestProcessIdentity, GuestProcessStartTimeTicks,
|
||||
HostCoordinatorTerminalRecorder, IDENTITY_DEADLINE, InodeId, InputModes, Instant, LocalModes,
|
||||
Mutex, OFlags, OpenptFlags, OptionalActions, OsString, OutputModes, POLL_INTERVAL,
|
||||
PRIVATE_SESSION_EXEC_ROLE, PRIVATE_SESSION_IDENTITY_ROLE, PersistedHostProcessOwnership,
|
||||
PidfsProcessIdentityV1, ProcessExit, ProcessOwnershipCommitAck, ProcessOwnershipContext,
|
||||
PtyIdentity, RetainedProcess, SANDBOX_INIT_DESTINATION, SessionId, SessionTerminal,
|
||||
SessionTerminalFactory, SessionTerminalFields, SessionTerminalProcess, SessionTerminalState,
|
||||
SpecialCodeIndex, StagedProcessOwnership, TargetCommand, TerminalAcquisition, TerminalError,
|
||||
TerminalModesV2, TerminalResize, Winsize, child_broker, current_process_identity, fcntl_getfl,
|
||||
fcntl_setfl, fstat, grantpt, io, ioctl_tiocgptpeer, openpt, release_physical, tcgetattr,
|
||||
tcsendbreak, tcsetattr, tcsetwinsize, unlockpt,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::AsRawFd;
|
||||
|
|
@ -40,6 +41,12 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
|
|||
return Err(TerminalError::AuthorityMismatch);
|
||||
}
|
||||
let (master, slave, pty) = allocate_pty()?;
|
||||
if let Err(error) = configure_pty(&slave, &request.initial_modes, request.initial_size) {
|
||||
drop(master);
|
||||
drop(slave);
|
||||
recorder.clean(clean_terminal(request.generation_id, request.session_id)?)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
remove_pid_file(&request.internal_pid_file)?;
|
||||
let mut target = TargetCommand::new(request.runsc_program.clone());
|
||||
for argument in &request.runsc_global_arguments {
|
||||
|
|
@ -308,6 +315,15 @@ impl SessionTerminalProcess for RunscSessionTerminal {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn send_break(&self) -> io::Result<()> {
|
||||
tcsendbreak(
|
||||
self.master
|
||||
.as_ref()
|
||||
.ok_or_else(|| io::Error::other("PTY is closed"))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
|
||||
self.master
|
||||
.as_mut()
|
||||
|
|
@ -338,12 +354,13 @@ impl SessionTerminalProcess for RunscSessionTerminal {
|
|||
}
|
||||
|
||||
fn group_absent(&self) -> Result<bool, TerminalError> {
|
||||
if observe_guest_identity(&self.request, self.guest.leader_pid.get())?
|
||||
.is_some_and(|observed| observed != self.guest)
|
||||
{
|
||||
let leader_matches = observe_guest_identity(&self.request, self.guest.leader_pid.get())?
|
||||
.is_none_or(|observed| observed == self.guest);
|
||||
let group_absent = observe_group_absent(&self.request, self.guest.process_group_id.get())?;
|
||||
if !leader_matches && !group_absent {
|
||||
return Err(TerminalError::GuestIdentityMismatch);
|
||||
}
|
||||
observe_group_absent(&self.request, self.guest.process_group_id.get())
|
||||
Ok(group_absent)
|
||||
}
|
||||
|
||||
fn terminate_exec(&self) -> io::Result<()> {
|
||||
|
|
@ -393,6 +410,60 @@ fn allocate_pty() -> io::Result<(File, File, PtyIdentity)> {
|
|||
Ok((File::from(master), File::from(slave), pty))
|
||||
}
|
||||
|
||||
fn configure_pty(slave: &File, modes: &TerminalModesV2, size: TerminalResize) -> io::Result<()> {
|
||||
if modes.line_discipline != 0 {
|
||||
return Err(io::Error::other("unsupported terminal line discipline"));
|
||||
}
|
||||
let mut configured = tcgetattr(slave)?;
|
||||
configured.input_modes = InputModes::from_bits(modes.input_modes)
|
||||
.ok_or_else(|| io::Error::other("unsupported terminal input modes"))?;
|
||||
configured.output_modes = OutputModes::from_bits(modes.output_modes)
|
||||
.ok_or_else(|| io::Error::other("unsupported terminal output modes"))?;
|
||||
configured.control_modes = ControlModes::from_bits(modes.control_modes)
|
||||
.ok_or_else(|| io::Error::other("unsupported terminal control modes"))?;
|
||||
configured.local_modes = LocalModes::from_bits(modes.local_modes)
|
||||
.ok_or_else(|| io::Error::other("unsupported terminal local modes"))?;
|
||||
configured.line_discipline = modes.line_discipline;
|
||||
configured.set_input_speed(modes.input_speed)?;
|
||||
configured.set_output_speed(modes.output_speed)?;
|
||||
for (index, value) in terminal_control_codes(modes) {
|
||||
configured.special_codes[index] = value;
|
||||
}
|
||||
tcsetattr(slave, OptionalActions::Now, &configured)?;
|
||||
tcsetwinsize(
|
||||
slave,
|
||||
Winsize {
|
||||
ws_row: size.rows.get(),
|
||||
ws_col: size.columns.get(),
|
||||
ws_xpixel: size.pixel_width,
|
||||
ws_ypixel: size.pixel_height,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn terminal_control_codes(modes: &TerminalModesV2) -> [(SpecialCodeIndex, u8); 17] {
|
||||
[
|
||||
(SpecialCodeIndex::VINTR, modes.interrupt),
|
||||
(SpecialCodeIndex::VQUIT, modes.quit),
|
||||
(SpecialCodeIndex::VERASE, modes.erase),
|
||||
(SpecialCodeIndex::VKILL, modes.kill),
|
||||
(SpecialCodeIndex::VEOF, modes.eof),
|
||||
(SpecialCodeIndex::VTIME, modes.time),
|
||||
(SpecialCodeIndex::VMIN, modes.minimum),
|
||||
(SpecialCodeIndex::VSWTC, modes.switch),
|
||||
(SpecialCodeIndex::VSTART, modes.start),
|
||||
(SpecialCodeIndex::VSTOP, modes.stop),
|
||||
(SpecialCodeIndex::VSUSP, modes.suspend),
|
||||
(SpecialCodeIndex::VEOL, modes.end_of_line),
|
||||
(SpecialCodeIndex::VREPRINT, modes.reprint),
|
||||
(SpecialCodeIndex::VDISCARD, modes.discard),
|
||||
(SpecialCodeIndex::VWERASE, modes.word_erase),
|
||||
(SpecialCodeIndex::VLNEXT, modes.literal_next),
|
||||
(SpecialCodeIndex::VEOL2, modes.end_of_line_2),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn staged_terminal(
|
||||
request: &TerminalAcquisition,
|
||||
pty: PtyIdentity,
|
||||
|
|
@ -619,3 +690,71 @@ fn hex_lower(bytes: &[u8]) -> String {
|
|||
output
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod configuration_tests {
|
||||
use super::*;
|
||||
use rustix::termios::tcgetwinsize;
|
||||
|
||||
#[test]
|
||||
fn pty_modes_and_size_are_applied_before_guest_start() {
|
||||
let (_master, slave, _) = allocate_pty().unwrap();
|
||||
let mut original = tcgetattr(&slave).unwrap();
|
||||
original.set_input_speed(original.input_speed()).unwrap();
|
||||
original.set_output_speed(original.output_speed()).unwrap();
|
||||
let mut modes = modes_from(&original);
|
||||
modes.local_modes ^= LocalModes::ECHO.bits();
|
||||
modes.interrupt = 7;
|
||||
modes.erase = 8;
|
||||
let size = TerminalResize {
|
||||
session_id: SessionId::new([1; 32]),
|
||||
rows: 55.try_into().unwrap(),
|
||||
columns: 144.try_into().unwrap(),
|
||||
pixel_width: 901,
|
||||
pixel_height: 502,
|
||||
};
|
||||
|
||||
configure_pty(&slave, &modes, size).unwrap();
|
||||
|
||||
let configured = tcgetattr(&slave).unwrap();
|
||||
assert_eq!(modes_from(&configured), modes);
|
||||
assert_eq!(
|
||||
tcgetwinsize(&slave).unwrap(),
|
||||
Winsize {
|
||||
ws_row: 55,
|
||||
ws_col: 144,
|
||||
ws_xpixel: 901,
|
||||
ws_ypixel: 502,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn modes_from(modes: &rustix::termios::Termios) -> TerminalModesV2 {
|
||||
TerminalModesV2 {
|
||||
input_modes: modes.input_modes.bits(),
|
||||
output_modes: modes.output_modes.bits(),
|
||||
control_modes: modes.control_modes.bits(),
|
||||
local_modes: modes.local_modes.bits(),
|
||||
line_discipline: modes.line_discipline,
|
||||
input_speed: modes.input_speed(),
|
||||
output_speed: modes.output_speed(),
|
||||
interrupt: modes.special_codes[SpecialCodeIndex::VINTR],
|
||||
quit: modes.special_codes[SpecialCodeIndex::VQUIT],
|
||||
erase: modes.special_codes[SpecialCodeIndex::VERASE],
|
||||
kill: modes.special_codes[SpecialCodeIndex::VKILL],
|
||||
eof: modes.special_codes[SpecialCodeIndex::VEOF],
|
||||
time: modes.special_codes[SpecialCodeIndex::VTIME],
|
||||
minimum: modes.special_codes[SpecialCodeIndex::VMIN],
|
||||
switch: modes.special_codes[SpecialCodeIndex::VSWTC],
|
||||
start: modes.special_codes[SpecialCodeIndex::VSTART],
|
||||
stop: modes.special_codes[SpecialCodeIndex::VSTOP],
|
||||
suspend: modes.special_codes[SpecialCodeIndex::VSUSP],
|
||||
end_of_line: modes.special_codes[SpecialCodeIndex::VEOL],
|
||||
reprint: modes.special_codes[SpecialCodeIndex::VREPRINT],
|
||||
discard: modes.special_codes[SpecialCodeIndex::VDISCARD],
|
||||
word_erase: modes.special_codes[SpecialCodeIndex::VWERASE],
|
||||
literal_next: modes.special_codes[SpecialCodeIndex::VLNEXT],
|
||||
end_of_line_2: modes.special_codes[SpecialCodeIndex::VEOL2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ use lazaret_core::identity::{
|
|||
use lazaret_core::protocol::{VerifiedSession, encode_signed, issue_session_capability};
|
||||
use lazaret_core::records::{
|
||||
DeveloperDecisionKind, Domain, DurableDecision, Generation, GenerationIdentity, MailboxRequest,
|
||||
Participant, SandboxOwnership, Session, SessionBinding, TerminalInput, TerminalSignal,
|
||||
Participant, ProtocolVersion, SandboxOwnership, Session, SessionBinding, TerminalBreak,
|
||||
TerminalInput,
|
||||
};
|
||||
use lazaret_linux::filesystem::try_exclusive_flock;
|
||||
use tempfile::TempDir;
|
||||
|
|
@ -352,6 +353,17 @@ impl SessionTerminalProcess for MockProcess {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn send_break(&self) -> io::Result<()> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("break".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let Some(output) = state.output.pop_front() else {
|
||||
|
|
@ -399,7 +411,7 @@ impl SessionTerminalProcess for MockProcess {
|
|||
if let Some(hook) = hook {
|
||||
hook();
|
||||
}
|
||||
if identity_mismatch {
|
||||
if identity_mismatch && !group_absent {
|
||||
return Err(TerminalError::GuestIdentityMismatch);
|
||||
}
|
||||
Ok(group_absent)
|
||||
|
|
@ -529,6 +541,43 @@ fn acquisition(session_id: SessionId) -> TerminalAcquisition {
|
|||
environment: BTreeMap::new(),
|
||||
internal_pid_file: "/tmp/not-used-by-mock".into(),
|
||||
argv: vec![b"/bin/agent".to_vec()],
|
||||
initial_size: TerminalResize {
|
||||
session_id,
|
||||
rows: NonZeroU16::new(24).unwrap(),
|
||||
columns: NonZeroU16::new(80).unwrap(),
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
},
|
||||
initial_modes: terminal_modes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_modes() -> TerminalModesV2 {
|
||||
TerminalModesV2 {
|
||||
input_modes: 0,
|
||||
output_modes: 0,
|
||||
control_modes: 0,
|
||||
local_modes: 0,
|
||||
line_discipline: 0,
|
||||
input_speed: 38_400,
|
||||
output_speed: 38_400,
|
||||
interrupt: 3,
|
||||
quit: 28,
|
||||
erase: 127,
|
||||
kill: 21,
|
||||
eof: 4,
|
||||
time: 0,
|
||||
minimum: 1,
|
||||
switch: 0,
|
||||
start: 17,
|
||||
stop: 19,
|
||||
suspend: 26,
|
||||
end_of_line: 0,
|
||||
reprint: 18,
|
||||
discard: 15,
|
||||
word_erase: 23,
|
||||
literal_next: 22,
|
||||
end_of_line_2: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ fn durable_detach_failure_and_sandbox_release_revoke_active_terminal() {
|
|||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"refused".to_vec(),
|
||||
}),
|
||||
|
|
@ -102,7 +102,7 @@ fn domain_container_hc_and_authority_read_mismatch_use_host_only_release() {
|
|||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"refused".to_vec(),
|
||||
}),
|
||||
|
|
@ -142,7 +142,7 @@ fn explicit_close_and_loss_revalidate_before_guest_cleanup() {
|
|||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -183,7 +183,7 @@ fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
|
|||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -213,7 +213,7 @@ fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
|
|||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -236,9 +236,8 @@ fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action
|
|||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(8),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
session_id: session,
|
||||
signal: TerminalSignalKind::Interrupt,
|
||||
SessionTerminalEventV2::Break(TerminalBreak {
|
||||
session_id: session
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::StaleGeneration)
|
||||
|
|
@ -266,14 +265,14 @@ fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn resize_and_terminal_signals_use_the_exact_group_map() {
|
||||
fn resize_break_and_input_use_only_the_exact_pty() {
|
||||
let session = id(6);
|
||||
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Resize(TerminalResize {
|
||||
SessionTerminalEventV2::Resize(TerminalResize {
|
||||
session_id: session,
|
||||
rows: NonZeroU16::new(40).unwrap(),
|
||||
columns: NonZeroU16::new(120).unwrap(),
|
||||
|
|
@ -282,49 +281,49 @@ fn resize_and_terminal_signals_use_the_exact_group_map() {
|
|||
}),
|
||||
)
|
||||
.unwrap();
|
||||
for (kind, expected) in [
|
||||
(TerminalSignalKind::Interrupt, "signal:INT"),
|
||||
(TerminalSignalKind::Quit, "signal:QUIT"),
|
||||
(TerminalSignalKind::Suspend, "signal:TSTP"),
|
||||
] {
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
session_id: session,
|
||||
signal: kind,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(factory.log.lock().unwrap().last().unwrap(), expected);
|
||||
}
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEventV2::Break(TerminalBreak {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: vec![3],
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&factory.log.lock().unwrap()[..2],
|
||||
&["resize:40x120", "signal:WINCH"]
|
||||
&factory.log.lock().unwrap()[..3],
|
||||
&["resize:40x120", "break", "input:1"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_guest_identity_refuses_numeric_group_action() {
|
||||
fn keyboard_input_does_not_target_the_recorded_group() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
factory.state(0).lock().unwrap().guest.identity_mismatch = true;
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
signal: TerminalSignalKind::Interrupt,
|
||||
bytes: vec![3],
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::GuestIdentityMismatch)
|
||||
));
|
||||
assert!(factory.log.lock().unwrap().is_empty());
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(factory.log.lock().unwrap().as_slice(), ["input:1"]);
|
||||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -341,6 +340,47 @@ fn changed_guest_identity_refuses_numeric_group_action() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_leader_with_absent_group_closes_clean() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
{
|
||||
let state = factory.state(0);
|
||||
let mut state = state.lock().unwrap();
|
||||
state.guest.identity_mismatch = true;
|
||||
state.guest.group_present = false;
|
||||
}
|
||||
|
||||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))
|
||||
);
|
||||
assert!(!engine.contains(session));
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::Clean
|
||||
);
|
||||
assert!(
|
||||
!factory
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.starts_with("signal:"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_leader_with_live_group_closes_with_release_intent() {
|
||||
let session = id(6);
|
||||
|
|
@ -355,7 +395,7 @@ fn absent_leader_with_live_group_closes_with_release_intent() {
|
|||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -412,7 +452,7 @@ fn persistent_guest_group_closes_session_with_release_intent() {
|
|||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -430,6 +470,40 @@ fn persistent_guest_group_closes_session_with_release_intent() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_absence_cleans_retained_terminal_after_exact_exec_absence() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
factory.state(0).lock().unwrap().guest.clears_after_signal = "never";
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
engine
|
||||
.clean_after_sandbox_absence(
|
||||
GenerationId::new(7),
|
||||
ContainerId::new(bytes(9)),
|
||||
process(70),
|
||||
|_| {
|
||||
Err(io::Error::from_raw_os_error(
|
||||
rustix::io::Errno::SRCH.raw_os_error(),
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::Clean
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn running_store_failures_release_every_live_partial_handle() {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
|||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -91,7 +91,7 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
|||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"reattach".to_vec(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
session_id: binding.session_id,
|
||||
};
|
||||
let batch = control
|
||||
.exchange(InteractiveSessionEvent::Poll {
|
||||
.exchange(InteractiveSessionEventV2::Poll {
|
||||
session_id: binding.session_id,
|
||||
})
|
||||
.unwrap();
|
||||
|
|
@ -111,8 +111,8 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
pixel_height: 900,
|
||||
};
|
||||
control
|
||||
.exchange(InteractiveSessionEvent::Terminal(
|
||||
SessionTerminalEvent::Resize(resumed_size),
|
||||
.exchange(InteractiveSessionEventV2::Terminal(
|
||||
SessionTerminalEventV2::Resize(resumed_size),
|
||||
))
|
||||
.unwrap();
|
||||
let signed_decision = encode_signed(
|
||||
|
|
@ -128,7 +128,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
)
|
||||
.unwrap();
|
||||
control
|
||||
.exchange(InteractiveSessionEvent::PromptDecision {
|
||||
.exchange(InteractiveSessionEventV2::PromptDecision {
|
||||
session_id: binding.session_id,
|
||||
signed_decision: signed_decision.clone(),
|
||||
})
|
||||
|
|
@ -140,10 +140,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
assert_eq!(routed.signed_decision, signed_decision);
|
||||
assert_eq!(routed.decision.decision, DeveloperDecisionKind::Deny);
|
||||
assert_eq!(routed.resumed_size, Some(resumed_size));
|
||||
assert_eq!(
|
||||
factory.log.lock().unwrap().as_slice(),
|
||||
["resize:55x144", "signal:WINCH"]
|
||||
);
|
||||
assert_eq!(factory.log.lock().unwrap().as_slice(), ["resize:55x144"]);
|
||||
assert!(
|
||||
!factory
|
||||
.log
|
||||
|
|
@ -169,7 +166,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
.unwrap()
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: binding.session_id,
|
||||
bytes: b"sandbox-input".to_vec(),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue