Decompose oversized Rust files by responsibility
This commit is contained in:
parent
03bfe92ca0
commit
ee9201b057
7 changed files with 18648 additions and 18513 deletions
539
crates/lazaret-host-coordinator/src/terminal/engine.rs
Normal file
539
crates/lazaret-host-coordinator/src/terminal/engine.rs
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
impl SessionTerminalEngine {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<HostCoordinatorDomainStore>,
|
||||
factory: Arc<dyn SessionTerminalFactory>,
|
||||
cancellation: Arc<dyn PromptRouteCancellation>,
|
||||
) -> Self {
|
||||
Self {
|
||||
recorder: HostCoordinatorTerminalRecorder::new(store),
|
||||
factory,
|
||||
cancellation,
|
||||
active: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses duplicate identity or any durable, PTY, causal acquisition, or guest failure.
|
||||
pub fn acquire(&mut self, request: &TerminalAcquisition) -> Result<(), TerminalError> {
|
||||
if self.active.contains_key(&request.session_id) {
|
||||
return Err(TerminalError::DuplicateSession);
|
||||
}
|
||||
if self.recorder.require_authority(request)? != TerminalRoute::Sandbox {
|
||||
return Err(TerminalError::AuthorityMismatch);
|
||||
}
|
||||
let intent = terminal_snapshot(
|
||||
request,
|
||||
SessionTerminalState::Intent,
|
||||
PersistedHostProcessOwnership::unassigned(),
|
||||
None,
|
||||
PersistedHostProcessOwnership::unassigned(),
|
||||
None,
|
||||
)?;
|
||||
self.recorder.put(intent)?;
|
||||
let (record, process) = self.factory.acquire(request, &self.recorder)?;
|
||||
self.active.insert(
|
||||
request.session_id,
|
||||
ActiveTerminal {
|
||||
generation_id: request.generation_id,
|
||||
request: request.clone(),
|
||||
route: TerminalRoute::Sandbox,
|
||||
record,
|
||||
process,
|
||||
releasing: false,
|
||||
pending_input: VecDeque::new(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses stale, unknown, detached, or releasing Session Terminals.
|
||||
pub fn set_route(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
generation_id: GenerationId,
|
||||
route: TerminalRoute,
|
||||
) -> Result<(), TerminalError> {
|
||||
let authoritative = self.refresh_route(session_id, generation_id)?;
|
||||
if authoritative != route {
|
||||
return Err(TerminalError::InactiveRoute);
|
||||
}
|
||||
let terminal = self.active_mut(session_id, generation_id)?;
|
||||
if terminal.releasing || route == TerminalRoute::Detached {
|
||||
return Err(TerminalError::Releasing);
|
||||
}
|
||||
terminal.route = route;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses stale, unknown, inactive, releasing, oversized, or failed terminal events.
|
||||
pub fn handle(
|
||||
&mut self,
|
||||
generation_id: GenerationId,
|
||||
event: SessionTerminalEvent,
|
||||
) -> 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,
|
||||
};
|
||||
if matches!(event, SessionTerminalEvent::Close(_)) {
|
||||
return self.close(
|
||||
session_id,
|
||||
generation_id,
|
||||
None,
|
||||
true,
|
||||
GuestReleaseAuthority::Validate,
|
||||
);
|
||||
}
|
||||
let authoritative = self.refresh_route(session_id, generation_id)?;
|
||||
if authoritative != TerminalRoute::Sandbox {
|
||||
return Err(TerminalError::InactiveRoute);
|
||||
}
|
||||
let terminal = self.active_mut(session_id, generation_id)?;
|
||||
if terminal.releasing {
|
||||
return Err(TerminalError::Releasing);
|
||||
}
|
||||
if terminal.route != TerminalRoute::Sandbox {
|
||||
return Err(TerminalError::InactiveRoute);
|
||||
}
|
||||
match event {
|
||||
SessionTerminalEvent::Input(input) => {
|
||||
if input.bytes.len() > MAX_EVENT_BYTES {
|
||||
return Err(TerminalError::OversizedInput);
|
||||
}
|
||||
if terminal.pending_input.len() + input.bytes.len() > MAX_INPUT_QUEUE_BYTES {
|
||||
return Err(TerminalError::Backpressure);
|
||||
}
|
||||
terminal.pending_input.extend(input.bytes);
|
||||
flush_input(terminal)?;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
SessionTerminalEvent::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",
|
||||
})?;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
SessionTerminalEvent::Close(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses stale or unknown sessions and propagates terminal read or close failures.
|
||||
pub fn poll(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
generation_id: GenerationId,
|
||||
) -> Result<Vec<SessionTerminalMessage>, TerminalError> {
|
||||
let authoritative = self.refresh_route(session_id, generation_id)?;
|
||||
let terminal = self.active_mut(session_id, generation_id)?;
|
||||
if terminal.process.exec_exited()? {
|
||||
return self.close(
|
||||
session_id,
|
||||
generation_id,
|
||||
None,
|
||||
true,
|
||||
GuestReleaseAuthority::Validate,
|
||||
);
|
||||
}
|
||||
if authoritative != TerminalRoute::Sandbox || terminal.releasing {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
flush_input(terminal)?;
|
||||
let mut bytes = [0_u8; MAX_OUTPUT_BYTES];
|
||||
match terminal.process.read_output(&mut bytes) {
|
||||
Ok(0) => self.close(
|
||||
session_id,
|
||||
generation_id,
|
||||
Some(TerminalLoss::PtyLost),
|
||||
true,
|
||||
GuestReleaseAuthority::Validate,
|
||||
),
|
||||
Ok(length) => Ok(vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id,
|
||||
bytes: bytes[..length].to_vec(),
|
||||
})]),
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(Vec::new()),
|
||||
Err(_) => self.close(
|
||||
session_id,
|
||||
generation_id,
|
||||
Some(TerminalLoss::PtyLost),
|
||||
true,
|
||||
GuestReleaseAuthority::Validate,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses stale or unknown sessions and propagates ordered release failures.
|
||||
pub fn lose(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
generation_id: GenerationId,
|
||||
loss: TerminalLoss,
|
||||
) -> Result<Vec<SessionTerminalMessage>, TerminalError> {
|
||||
if loss == TerminalLoss::HostCoordinatorLost {
|
||||
let mut terminal = self
|
||||
.active
|
||||
.remove(&session_id)
|
||||
.ok_or(TerminalError::UnknownSession)?;
|
||||
if terminal.generation_id != generation_id {
|
||||
self.active.insert(session_id, terminal);
|
||||
return Err(TerminalError::StaleGeneration);
|
||||
}
|
||||
terminal.process.close_pty();
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let connected = !matches!(
|
||||
loss,
|
||||
TerminalLoss::ClientDisconnected | TerminalLoss::HostCoordinatorLost
|
||||
);
|
||||
self.close(
|
||||
session_id,
|
||||
generation_id,
|
||||
Some(loss),
|
||||
connected,
|
||||
GuestReleaseAuthority::Validate,
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn contains(&self, session_id: SessionId) -> bool {
|
||||
self.active.contains_key(&session_id)
|
||||
}
|
||||
|
||||
fn refresh_route(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
generation_id: GenerationId,
|
||||
) -> Result<TerminalRoute, TerminalError> {
|
||||
let active_generation = self
|
||||
.active
|
||||
.get(&session_id)
|
||||
.ok_or(TerminalError::UnknownSession)?
|
||||
.generation_id;
|
||||
if active_generation != generation_id {
|
||||
let cleanup = self.close(
|
||||
session_id,
|
||||
active_generation,
|
||||
Some(TerminalLoss::ExecClientLost),
|
||||
false,
|
||||
GuestReleaseAuthority::HostOnly,
|
||||
);
|
||||
return Err(with_cleanup(TerminalError::StaleGeneration, cleanup));
|
||||
}
|
||||
let request = {
|
||||
let terminal = self.active_mut(session_id, generation_id)?;
|
||||
terminal.request.clone()
|
||||
};
|
||||
match self.recorder.require_authority(&request) {
|
||||
Ok(TerminalRoute::Detached) => {
|
||||
let cleanup = self.close(
|
||||
session_id,
|
||||
generation_id,
|
||||
Some(TerminalLoss::ExecClientLost),
|
||||
false,
|
||||
GuestReleaseAuthority::HostOnly,
|
||||
);
|
||||
Err(with_cleanup(TerminalError::AuthorityMismatch, cleanup))
|
||||
}
|
||||
Err(error) => {
|
||||
let cleanup = self.close(
|
||||
session_id,
|
||||
generation_id,
|
||||
Some(TerminalLoss::ExecClientLost),
|
||||
false,
|
||||
GuestReleaseAuthority::HostOnly,
|
||||
);
|
||||
Err(with_cleanup(error, cleanup))
|
||||
}
|
||||
Ok(route) => {
|
||||
self.active_mut(session_id, generation_id)?.route = route;
|
||||
Ok(route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn active_mut(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
generation_id: GenerationId,
|
||||
) -> Result<&mut ActiveTerminal, TerminalError> {
|
||||
let terminal = self
|
||||
.active
|
||||
.get_mut(&session_id)
|
||||
.ok_or(TerminalError::UnknownSession)?;
|
||||
if terminal.generation_id != generation_id {
|
||||
return Err(TerminalError::StaleGeneration);
|
||||
}
|
||||
Ok(terminal)
|
||||
}
|
||||
|
||||
fn close(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
generation_id: GenerationId,
|
||||
loss: Option<TerminalLoss>,
|
||||
connected: bool,
|
||||
authority: GuestReleaseAuthority,
|
||||
) -> Result<Vec<SessionTerminalMessage>, TerminalError> {
|
||||
let active_generation = self
|
||||
.active
|
||||
.get(&session_id)
|
||||
.ok_or(TerminalError::UnknownSession)?
|
||||
.generation_id;
|
||||
if active_generation != generation_id {
|
||||
let cleanup = self.close(
|
||||
session_id,
|
||||
active_generation,
|
||||
loss,
|
||||
false,
|
||||
GuestReleaseAuthority::HostOnly,
|
||||
);
|
||||
return Err(with_cleanup(TerminalError::StaleGeneration, cleanup));
|
||||
}
|
||||
let mut terminal = self
|
||||
.active
|
||||
.remove(&session_id)
|
||||
.ok_or(TerminalError::UnknownSession)?;
|
||||
let was_serviceable = matches!(authority, GuestReleaseAuthority::Validate)
|
||||
&& (matches!(
|
||||
self.recorder.require_authority(&terminal.request),
|
||||
Ok(TerminalRoute::Sandbox | TerminalRoute::Prompt | TerminalRoute::HostProcess)
|
||||
) || (terminal.record.state() == SessionTerminalState::ReleaseIntent
|
||||
&& self
|
||||
.recorder
|
||||
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
||||
.is_ok()));
|
||||
let result = (|| {
|
||||
terminal.releasing = true;
|
||||
terminal.route = TerminalRoute::Detached;
|
||||
let release =
|
||||
copy_terminal_state(&terminal.record, SessionTerminalState::ReleaseIntent)?;
|
||||
self.recorder.begin_release(release.clone())?;
|
||||
terminal.record = release;
|
||||
self.cancellation.cancel_for_close(session_id)?;
|
||||
|
||||
let mut authorize_guest = || {
|
||||
was_serviceable
|
||||
&& self
|
||||
.recorder
|
||||
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
||||
.is_ok()
|
||||
};
|
||||
let (mut output, exit, guest_absent) = release_physical(
|
||||
terminal.process.as_mut(),
|
||||
session_id,
|
||||
connected,
|
||||
&mut authorize_guest,
|
||||
)?;
|
||||
if guest_absent {
|
||||
let clean = clean_terminal(generation_id, session_id)?;
|
||||
self.recorder.clean(clean)?;
|
||||
}
|
||||
if connected {
|
||||
output.push(SessionTerminalMessage::Exit(exit_message(
|
||||
session_id, loss, exit,
|
||||
)?));
|
||||
}
|
||||
Ok(output)
|
||||
})();
|
||||
if result.is_err() {
|
||||
self.active.insert(session_id, terminal);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn release_physical(
|
||||
process: &mut dyn SessionTerminalProcess,
|
||||
session_id: SessionId,
|
||||
connected: bool,
|
||||
authorize_guest: &mut dyn FnMut() -> bool,
|
||||
) -> Result<(Vec<SessionTerminalMessage>, ProcessExit, bool), TerminalError> {
|
||||
let mut failure = None;
|
||||
let mut guest_authorized = authorize_guest();
|
||||
if guest_authorized {
|
||||
for signal in ["HUP", "TERM", "KILL"] {
|
||||
if !authorize_guest() {
|
||||
guest_authorized = false;
|
||||
break;
|
||||
}
|
||||
match process.group_absent() {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
failure.get_or_insert(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !authorize_guest() {
|
||||
guest_authorized = false;
|
||||
break;
|
||||
}
|
||||
if let Err(error) = process.signal_group(signal) {
|
||||
failure.get_or_insert(error);
|
||||
break;
|
||||
}
|
||||
match wait_for_group_absence(process, authorize_guest) {
|
||||
Ok(GuestWaitOutcome::Absent) => break,
|
||||
Ok(GuestWaitOutcome::TimedOut) => {}
|
||||
Ok(GuestWaitOutcome::Revoked) => {
|
||||
guest_authorized = false;
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
failure.get_or_insert(error);
|
||||
guest_authorized = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match process.exec_exited() {
|
||||
Ok(false) => {
|
||||
if let Err(error) = process.terminate_exec() {
|
||||
failure.get_or_insert(error.into());
|
||||
}
|
||||
}
|
||||
Ok(true) => {}
|
||||
Err(error) => {
|
||||
failure.get_or_insert(error.into());
|
||||
}
|
||||
}
|
||||
let exit = match process.reap_exec() {
|
||||
Ok(exit) => Some(exit),
|
||||
Err(error) => {
|
||||
failure.get_or_insert(error.into());
|
||||
None
|
||||
}
|
||||
};
|
||||
let output = drain_output(process, session_id, connected);
|
||||
process.close_pty();
|
||||
if guest_authorized && authorize_guest() {
|
||||
match process.group_absent() {
|
||||
Ok(true) if authorize_guest() => {}
|
||||
Ok(true) => guest_authorized = false,
|
||||
Ok(false) => {
|
||||
failure.get_or_insert(TerminalError::GuestGroupStillPresent);
|
||||
}
|
||||
Err(error) => {
|
||||
failure.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
guest_authorized = false;
|
||||
}
|
||||
if let Some(error) = failure {
|
||||
return Err(error);
|
||||
}
|
||||
Ok((
|
||||
output,
|
||||
exit.ok_or_else(|| io::Error::other("exec client exit is unavailable"))?,
|
||||
guest_authorized,
|
||||
))
|
||||
}
|
||||
|
||||
fn drain_output(
|
||||
process: &mut dyn SessionTerminalProcess,
|
||||
session_id: SessionId,
|
||||
connected: bool,
|
||||
) -> Vec<SessionTerminalMessage> {
|
||||
let mut output = Vec::new();
|
||||
if !connected {
|
||||
return output;
|
||||
}
|
||||
let deadline = Instant::now() + DRAIN_DEADLINE;
|
||||
let mut bytes = [0_u8; MAX_OUTPUT_BYTES];
|
||||
let mut drained = 0;
|
||||
while Instant::now() < deadline && drained < MAX_DRAIN_BYTES {
|
||||
match process.read_output(&mut bytes) {
|
||||
Ok(0) => break,
|
||||
Ok(length) => {
|
||||
let length = length.min(MAX_DRAIN_BYTES - drained);
|
||||
drained += length;
|
||||
output.push(SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id,
|
||||
bytes: bytes[..length].to_vec(),
|
||||
}));
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn with_cleanup<T>(original: TerminalError, cleanup: Result<T, TerminalError>) -> TerminalError {
|
||||
match cleanup {
|
||||
Ok(_) => original,
|
||||
Err(cleanup) => TerminalError::AcquisitionCleanup {
|
||||
original: Box::new(original),
|
||||
cleanup: Some(Box::new(cleanup)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_group_absence(
|
||||
process: &dyn SessionTerminalProcess,
|
||||
authorize_guest: &mut dyn FnMut() -> bool,
|
||||
) -> Result<GuestWaitOutcome, TerminalError> {
|
||||
let deadline = Instant::now() + SIGNAL_GRACE;
|
||||
while Instant::now() < deadline {
|
||||
if !authorize_guest() {
|
||||
return Ok(GuestWaitOutcome::Revoked);
|
||||
}
|
||||
if process.group_absent()? {
|
||||
return Ok(GuestWaitOutcome::Absent);
|
||||
}
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
Ok(GuestWaitOutcome::TimedOut)
|
||||
}
|
||||
|
||||
fn flush_input(terminal: &mut ActiveTerminal) -> Result<(), TerminalError> {
|
||||
while !terminal.pending_input.is_empty() {
|
||||
let (first, second) = terminal.pending_input.as_slices();
|
||||
let bytes = if first.is_empty() { second } else { first };
|
||||
match terminal.process.write_input(bytes) {
|
||||
Ok(0) => {
|
||||
return Err(io::Error::new(io::ErrorKind::WriteZero, "PTY input closed").into());
|
||||
}
|
||||
Ok(length) => {
|
||||
terminal.pending_input.drain(..length);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exit_message(
|
||||
session_id: SessionId,
|
||||
loss: Option<TerminalLoss>,
|
||||
exit: ProcessExit,
|
||||
) -> Result<SessionExit, TerminalError> {
|
||||
if loss.is_some() {
|
||||
return Ok(SessionExit::runtime_lost(session_id));
|
||||
}
|
||||
match (exit.exit_code, exit.terminating_signal) {
|
||||
(Some(code), None) => Ok(SessionExit::exited(session_id, code)),
|
||||
(None, Some(_)) => Ok(SessionExit::terminated(session_id)),
|
||||
_ => Err(TerminalError::InvalidExit),
|
||||
}
|
||||
}
|
||||
|
||||
249
crates/lazaret-host-coordinator/src/terminal/interactive.rs
Normal file
249
crates/lazaret-host-coordinator/src/terminal/interactive.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
struct ActiveTerminal {
|
||||
generation_id: GenerationId,
|
||||
request: TerminalAcquisition,
|
||||
route: TerminalRoute,
|
||||
record: SessionTerminal,
|
||||
process: Box<dyn SessionTerminalProcess>,
|
||||
releasing: bool,
|
||||
pending_input: VecDeque<u8>,
|
||||
}
|
||||
|
||||
pub struct SessionTerminalEngine {
|
||||
recorder: HostCoordinatorTerminalRecorder,
|
||||
factory: Arc<dyn SessionTerminalFactory>,
|
||||
cancellation: Arc<dyn PromptRouteCancellation>,
|
||||
active: HashMap<SessionId, ActiveTerminal>,
|
||||
}
|
||||
|
||||
pub struct RunscInteractiveSessionExecutor {
|
||||
engine: Arc<Mutex<SessionTerminalEngine>>,
|
||||
prompt_transport: Arc<SessionPromptTransport>,
|
||||
domain_id: DomainId,
|
||||
generation_id: GenerationId,
|
||||
coordinator_id: CoordinatorId,
|
||||
container_id: ContainerId,
|
||||
host_coordinator: PidfsProcessIdentityV1,
|
||||
runsc_program: OsString,
|
||||
runsc_global_arguments: Vec<OsString>,
|
||||
runtime_root: PathBuf,
|
||||
}
|
||||
|
||||
impl RunscInteractiveSessionExecutor {
|
||||
/// Creates the generation-bound control adapter for the coordinator-owned terminal engine.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a run command without one exact `run`/bundle/container suffix.
|
||||
pub fn new(
|
||||
store: Arc<HostCoordinatorDomainStore>,
|
||||
generation: &lazaret_core::records::GenerationIdentity,
|
||||
container_id: ContainerId,
|
||||
host_coordinator: PidfsProcessIdentityV1,
|
||||
command: &lazaret_runtime_supervisor::sandbox::RunscCommand,
|
||||
runtime_root: PathBuf,
|
||||
) -> Result<Self, TerminalError> {
|
||||
let run = command
|
||||
.arguments()
|
||||
.iter()
|
||||
.position(|argument| argument == "run")
|
||||
.ok_or(TerminalError::AuthorityMismatch)?;
|
||||
if run + 3 != command.arguments().len() {
|
||||
return Err(TerminalError::AuthorityMismatch);
|
||||
}
|
||||
let prompt_transport = Arc::new(SessionPromptTransport::new(Arc::clone(&store)));
|
||||
Ok(Self {
|
||||
engine: Arc::new(Mutex::new(SessionTerminalEngine::new(
|
||||
store,
|
||||
Arc::new(RunscSessionTerminalFactory),
|
||||
prompt_transport.clone(),
|
||||
))),
|
||||
prompt_transport,
|
||||
domain_id: generation.domain_id,
|
||||
generation_id: generation.generation_id,
|
||||
coordinator_id: generation.coordinator_id,
|
||||
container_id,
|
||||
host_coordinator,
|
||||
runsc_program: command.program().to_owned(),
|
||||
runsc_global_arguments: command.arguments()[..run].to_vec(),
|
||||
runtime_root,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn prompt_transport(&self) -> Arc<SessionPromptTransport> {
|
||||
Arc::clone(&self.prompt_transport)
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
|
||||
fn start(
|
||||
&self,
|
||||
request: &InteractiveSessionRequest,
|
||||
) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
|
||||
if request.protocol_version != ProtocolVersion::V1
|
||||
|| request.initial_size.session_id != request.session_id
|
||||
{
|
||||
return Err(ControlError::InvalidRequest);
|
||||
}
|
||||
let acquisition = TerminalAcquisition {
|
||||
domain_id: self.domain_id,
|
||||
generation_id: self.generation_id,
|
||||
coordinator_id: self.coordinator_id,
|
||||
session_id: request.session_id,
|
||||
container_id: self.container_id,
|
||||
host_coordinator: self.host_coordinator,
|
||||
runsc_program: self.runsc_program.clone(),
|
||||
runsc_global_arguments: self.runsc_global_arguments.clone(),
|
||||
internal_pid_file: self.runtime_root.join(format!(
|
||||
"session-{}.pid",
|
||||
hex_session(request.session_id.as_bytes())
|
||||
)),
|
||||
argv: request.argv.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),
|
||||
prompt_transport: Arc::clone(&self.prompt_transport),
|
||||
generation_id: self.generation_id,
|
||||
session_id: request.session_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct RunscInteractiveControlSession {
|
||||
engine: Arc<Mutex<SessionTerminalEngine>>,
|
||||
prompt_transport: Arc<SessionPromptTransport>,
|
||||
generation_id: GenerationId,
|
||||
session_id: SessionId,
|
||||
}
|
||||
|
||||
impl InteractiveControlSession for RunscInteractiveControlSession {
|
||||
fn exchange(
|
||||
&mut self,
|
||||
event: InteractiveSessionEvent,
|
||||
) -> Result<InteractiveSessionBatch, ControlError> {
|
||||
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
|
||||
let mut messages = match event {
|
||||
InteractiveSessionEvent::Terminal(SessionTerminalEvent::Resize(resize)) => {
|
||||
if self
|
||||
.prompt_transport
|
||||
.retain_prompt_resize(resize)
|
||||
.map_err(|error| terminal_control_error(&error))?
|
||||
{
|
||||
Vec::new()
|
||||
} else {
|
||||
engine
|
||||
.handle(self.generation_id, SessionTerminalEvent::Resize(resize))
|
||||
.map_err(|error| terminal_control_error(&error))?
|
||||
}
|
||||
}
|
||||
InteractiveSessionEvent::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 => {
|
||||
Vec::new()
|
||||
}
|
||||
InteractiveSessionEvent::PromptDecision {
|
||||
session_id,
|
||||
signed_decision,
|
||||
} if session_id == self.session_id => {
|
||||
let decision = self
|
||||
.prompt_transport
|
||||
.route_decision(session_id, signed_decision)
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
if decision.decision.decision == lazaret_core::records::DeveloperDecisionKind::Deny
|
||||
{
|
||||
self.prompt_transport
|
||||
.restore_sandbox_route(session_id)
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
engine
|
||||
.set_route(session_id, self.generation_id, TerminalRoute::Sandbox)
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
if let Some(resize) = decision.resumed_size {
|
||||
engine
|
||||
.handle(self.generation_id, SessionTerminalEvent::Resize(resize))
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
}
|
||||
}
|
||||
self.prompt_transport
|
||||
.publish_decision(session_id, decision)
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
Vec::new()
|
||||
}
|
||||
InteractiveSessionEvent::PromptDecision { .. }
|
||||
| InteractiveSessionEvent::Poll { .. } => return Err(ControlError::InvalidRequest),
|
||||
};
|
||||
if let Some(prompt) = self
|
||||
.prompt_transport
|
||||
.prompt_for(self.session_id)
|
||||
.map_err(|error| terminal_control_error(&error))?
|
||||
{
|
||||
engine
|
||||
.set_route(self.session_id, self.generation_id, TerminalRoute::Prompt)
|
||||
.map_err(|error| terminal_control_error(&error))?;
|
||||
return Ok(InteractiveSessionBatch {
|
||||
session_id: self.session_id,
|
||||
messages: vec![InteractiveSessionMessage::Prompt(prompt)],
|
||||
});
|
||||
}
|
||||
if !messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))
|
||||
{
|
||||
messages.extend(
|
||||
engine
|
||||
.poll(self.session_id, self.generation_id)
|
||||
.map_err(|error| terminal_control_error(&error))?,
|
||||
);
|
||||
}
|
||||
Ok(InteractiveSessionBatch {
|
||||
session_id: self.session_id,
|
||||
messages: messages
|
||||
.into_iter()
|
||||
.map(InteractiveSessionMessage::Terminal)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunscInteractiveControlSession {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut engine) = self.engine.lock()
|
||||
&& engine.contains(self.session_id)
|
||||
{
|
||||
let _ = engine.lose(
|
||||
self.session_id,
|
||||
self.generation_id,
|
||||
TerminalLoss::ClientDisconnected,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_control_error(error: &TerminalError) -> ControlError {
|
||||
ControlError::Lifecycle(error.to_string())
|
||||
}
|
||||
|
||||
fn hex_session(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
bytes.iter().fold(String::new(), |mut output, byte| {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
595
crates/lazaret-host-coordinator/src/terminal/runsc.rs
Normal file
595
crates/lazaret-host-coordinator/src/terminal/runsc.rs
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
pub struct RunscSessionTerminalFactory;
|
||||
|
||||
impl SessionTerminalFactory for RunscSessionTerminalFactory {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn acquire(
|
||||
&self,
|
||||
request: &TerminalAcquisition,
|
||||
recorder: &HostCoordinatorTerminalRecorder,
|
||||
) -> Result<(SessionTerminal, Box<dyn SessionTerminalProcess>), TerminalError> {
|
||||
if request.argv.is_empty()
|
||||
|| request.argv[0].is_empty()
|
||||
|| request.argv.iter().any(|argument| argument.contains(&0))
|
||||
|| request.runsc_global_arguments.iter().any(|argument| {
|
||||
argument
|
||||
.as_encoded_bytes()
|
||||
.windows(14)
|
||||
.any(|part| part == b"console-socket")
|
||||
})
|
||||
{
|
||||
return Err(io::Error::other("interactive runsc request is invalid").into());
|
||||
}
|
||||
if current_process_identity()? != request.host_coordinator {
|
||||
return Err(TerminalError::AuthorityMismatch);
|
||||
}
|
||||
let (master, slave, pty) = allocate_pty()?;
|
||||
remove_pid_file(&request.internal_pid_file)?;
|
||||
let mut target = TargetCommand::new(request.runsc_program.clone());
|
||||
for argument in &request.runsc_global_arguments {
|
||||
target = target.argument(argument.clone());
|
||||
}
|
||||
target = target
|
||||
.argument("exec")
|
||||
.argument(format!(
|
||||
"-internal-pid-file={}",
|
||||
request.internal_pid_file.display()
|
||||
))
|
||||
.argument("-env")
|
||||
.argument(format!(
|
||||
"LAZARET_SESSION_ID={}",
|
||||
hex_lower(request.session_id.as_bytes())
|
||||
))
|
||||
.argument(hex_lower(request.container_id.as_bytes()))
|
||||
.argument(SANDBOX_INIT_PATH)
|
||||
.argument(PRIVATE_SESSION_EXEC_ROLE)
|
||||
.argument("--");
|
||||
for argument in &request.argv {
|
||||
target = target.argument(OsString::from_vec(argument.clone()));
|
||||
}
|
||||
target = match slave
|
||||
.try_clone()
|
||||
.and_then(|stdin| target.with_target_stdin(stdin))
|
||||
{
|
||||
Ok(target) => target,
|
||||
Err(error) => {
|
||||
drop(master);
|
||||
drop(slave);
|
||||
recorder.clean(clean_terminal(request.generation_id, request.session_id)?)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
let stage_request = request.clone();
|
||||
let stage_pty = pty;
|
||||
let staged_identity = Arc::new(Mutex::new(None));
|
||||
let callback_identity = Arc::clone(&staged_identity);
|
||||
let standard_io = match ChildStandardIo::terminal(slave) {
|
||||
Ok(standard_io) => standard_io,
|
||||
Err(error) => {
|
||||
drop(master);
|
||||
recorder.clean(clean_terminal(request.generation_id, request.session_id)?)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
let acquired = child_broker().acquire_with_stdio(
|
||||
request.host_coordinator,
|
||||
ProcessOwnershipContext {
|
||||
domain_id: request.domain_id,
|
||||
generation_id: request.generation_id,
|
||||
coordinator_id: request.coordinator_id,
|
||||
slot: lazaret_core::records::HostProcessOwnershipSlot::SessionTerminalExecClient(
|
||||
request.session_id,
|
||||
),
|
||||
},
|
||||
&target,
|
||||
standard_io,
|
||||
|staged| {
|
||||
let terminal = staged_terminal(&stage_request, stage_pty, staged)
|
||||
.map_err(|error| terminal_io(&error))?;
|
||||
recorder
|
||||
.put(terminal)
|
||||
.map_err(|error| terminal_io(&error))?;
|
||||
*callback_identity
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("staged terminal lock poisoned"))? =
|
||||
Some(staged.identity());
|
||||
Ok(())
|
||||
},
|
||||
|staged| Ok(ProcessOwnershipCommitAck::v1(staged)),
|
||||
);
|
||||
let process = match acquired {
|
||||
Ok(process) => process,
|
||||
Err(error) => {
|
||||
drop(master);
|
||||
clean_failed_blocked_acquisition(
|
||||
recorder,
|
||||
request,
|
||||
pty,
|
||||
*staged_identity
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("staged terminal lock poisoned"))?,
|
||||
)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
let guest = match wait_for_guest_identity(request, &process) {
|
||||
Ok(guest) => guest,
|
||||
Err(error) => {
|
||||
let release = partial_terminal_release(request, pty, process.identity())?;
|
||||
recorder.put(release)?;
|
||||
if !process.has_exited()? {
|
||||
process.send_signal(rustix::process::Signal::KILL)?;
|
||||
}
|
||||
child_broker().reap(process)?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let running = terminal_snapshot(
|
||||
request,
|
||||
SessionTerminalState::Running,
|
||||
PersistedHostProcessOwnership::committed(request.host_coordinator),
|
||||
Some(pty),
|
||||
PersistedHostProcessOwnership::committed(process.identity()),
|
||||
Some(guest),
|
||||
)?;
|
||||
let mut terminal_process = RunscSessionTerminal {
|
||||
request: request.clone(),
|
||||
master: Some(master),
|
||||
exec: Some(process),
|
||||
exit: None,
|
||||
guest,
|
||||
};
|
||||
let exec_client = terminal_process
|
||||
.exec
|
||||
.as_ref()
|
||||
.expect("new terminal retains its exec client")
|
||||
.identity();
|
||||
commit_running_or_cleanup(
|
||||
recorder,
|
||||
request,
|
||||
running.clone(),
|
||||
pty,
|
||||
exec_client,
|
||||
guest,
|
||||
&mut terminal_process,
|
||||
)?;
|
||||
Ok((running, Box::new(terminal_process)))
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_running_or_cleanup(
|
||||
recorder: &HostCoordinatorTerminalRecorder,
|
||||
request: &TerminalAcquisition,
|
||||
running: SessionTerminal,
|
||||
pty: PtyIdentity,
|
||||
exec_client: PidfsProcessIdentityV1,
|
||||
guest: GuestProcessIdentity,
|
||||
process: &mut dyn SessionTerminalProcess,
|
||||
) -> Result<(), TerminalError> {
|
||||
if let Err(original) = recorder.put(running) {
|
||||
let cleanup =
|
||||
cleanup_failed_running_commit(recorder, request, pty, exec_client, guest, process)
|
||||
.err()
|
||||
.map(Box::new);
|
||||
return Err(TerminalError::AcquisitionCleanup {
|
||||
original: Box::new(original),
|
||||
cleanup,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_failed_running_commit(
|
||||
recorder: &HostCoordinatorTerminalRecorder,
|
||||
request: &TerminalAcquisition,
|
||||
pty: PtyIdentity,
|
||||
exec_client: PidfsProcessIdentityV1,
|
||||
guest: GuestProcessIdentity,
|
||||
process: &mut dyn SessionTerminalProcess,
|
||||
) -> Result<(), TerminalError> {
|
||||
let observed = terminal_snapshot(
|
||||
request,
|
||||
SessionTerminalState::Intent,
|
||||
PersistedHostProcessOwnership::committed(request.host_coordinator),
|
||||
Some(pty),
|
||||
PersistedHostProcessOwnership::committed(exec_client),
|
||||
Some(guest),
|
||||
)?;
|
||||
let release = copy_terminal_state(&observed, SessionTerminalState::ReleaseIntent)?;
|
||||
let durable = recorder
|
||||
.put(observed)
|
||||
.and_then(|()| recorder.put(release.clone()));
|
||||
let mut authorize_guest = || {
|
||||
recorder
|
||||
.require_guest_signal_authority(request, &release)
|
||||
.is_ok()
|
||||
};
|
||||
let physical = release_physical(process, request.session_id, false, &mut authorize_guest)
|
||||
.map(|(_, _, guest_absent)| guest_absent);
|
||||
match (durable, physical) {
|
||||
(Err(durable), Err(physical)) => {
|
||||
return Err(TerminalError::AcquisitionCleanup {
|
||||
original: Box::new(durable),
|
||||
cleanup: Some(Box::new(physical)),
|
||||
});
|
||||
}
|
||||
(Err(error), Ok(_)) | (Ok(()), Err(error)) => return Err(error),
|
||||
(Ok(()), Ok(false)) => return Ok(()),
|
||||
(Ok(()), Ok(true)) => {}
|
||||
}
|
||||
recorder.clean(clean_terminal(request.generation_id, request.session_id)?)
|
||||
}
|
||||
|
||||
fn clean_failed_blocked_acquisition(
|
||||
recorder: &HostCoordinatorTerminalRecorder,
|
||||
request: &TerminalAcquisition,
|
||||
pty: PtyIdentity,
|
||||
staged: Option<PidfsProcessIdentityV1>,
|
||||
) -> Result<(), TerminalError> {
|
||||
if let Some(staged) = staged {
|
||||
recorder.put(partial_terminal_release(request, pty, staged)?)?;
|
||||
}
|
||||
recorder.clean(clean_terminal(request.generation_id, request.session_id)?)
|
||||
}
|
||||
|
||||
fn partial_terminal_release(
|
||||
request: &TerminalAcquisition,
|
||||
pty: PtyIdentity,
|
||||
exec_client: PidfsProcessIdentityV1,
|
||||
) -> Result<SessionTerminal, TerminalError> {
|
||||
terminal_snapshot(
|
||||
request,
|
||||
SessionTerminalState::ReleaseIntent,
|
||||
PersistedHostProcessOwnership::committed(request.host_coordinator),
|
||||
Some(pty),
|
||||
PersistedHostProcessOwnership::staged(exec_client),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
struct RunscSessionTerminal {
|
||||
request: TerminalAcquisition,
|
||||
master: Option<File>,
|
||||
exec: Option<RetainedProcess>,
|
||||
exit: Option<ProcessExit>,
|
||||
guest: GuestProcessIdentity,
|
||||
}
|
||||
|
||||
impl SessionTerminalProcess for RunscSessionTerminal {
|
||||
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.master
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::other("PTY is closed"))?
|
||||
.write(bytes)
|
||||
}
|
||||
|
||||
fn resize(&self, resize: TerminalResize) -> io::Result<()> {
|
||||
let master = self
|
||||
.master
|
||||
.as_ref()
|
||||
.ok_or_else(|| io::Error::other("PTY is closed"))?;
|
||||
tcsetwinsize(
|
||||
master,
|
||||
Winsize {
|
||||
ws_row: resize.rows.get(),
|
||||
ws_col: resize.columns.get(),
|
||||
ws_xpixel: resize.pixel_width,
|
||||
ws_ypixel: resize.pixel_height,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
|
||||
self.master
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::other("PTY is closed"))?
|
||||
.read(bytes)
|
||||
}
|
||||
|
||||
fn exec_exited(&self) -> io::Result<bool> {
|
||||
self.exec
|
||||
.as_ref()
|
||||
.map_or(Ok(true), RetainedProcess::has_exited)
|
||||
}
|
||||
|
||||
fn signal_group(&self, signal: &str) -> Result<(), TerminalError> {
|
||||
let observed = observe_guest_identity(&self.request, self.guest.leader_pid.get())?;
|
||||
if observed != Some(self.guest) {
|
||||
return Err(TerminalError::GuestIdentityMismatch);
|
||||
}
|
||||
let mut target = runsc_target(&self.request);
|
||||
target = target
|
||||
.argument("kill")
|
||||
.argument("-pgid")
|
||||
.argument(self.guest.process_group_id.get().to_string())
|
||||
.argument(hex_lower(self.request.container_id.as_bytes()))
|
||||
.argument(signal);
|
||||
child_broker().run_transient(&target)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return Err(TerminalError::GuestIdentityMismatch);
|
||||
}
|
||||
observe_group_absent(&self.request, self.guest.process_group_id.get())
|
||||
}
|
||||
|
||||
fn terminate_exec(&self) -> io::Result<()> {
|
||||
match self.exec.as_ref() {
|
||||
Some(exec) => exec.send_signal(rustix::process::Signal::KILL),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn reap_exec(&mut self) -> io::Result<ProcessExit> {
|
||||
let exit = if let Some(exit) = self.exit {
|
||||
exit
|
||||
} else {
|
||||
let exit = child_broker().reap_status(
|
||||
self.exec
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other("exec client exit is unavailable"))?,
|
||||
)?;
|
||||
self.exit = Some(exit);
|
||||
exit
|
||||
};
|
||||
remove_pid_file(&self.request.internal_pid_file)?;
|
||||
Ok(exit)
|
||||
}
|
||||
|
||||
fn close_pty(&mut self) {
|
||||
self.master.take();
|
||||
}
|
||||
}
|
||||
|
||||
fn allocate_pty() -> io::Result<(File, File, PtyIdentity)> {
|
||||
let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)?;
|
||||
grantpt(&master)?;
|
||||
unlockpt(&master)?;
|
||||
let slave = ioctl_tiocgptpeer(
|
||||
&master,
|
||||
OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC,
|
||||
)?;
|
||||
let flags = fcntl_getfl(&master)?;
|
||||
fcntl_setfl(&master, flags | OFlags::NONBLOCK)?;
|
||||
let stat = fstat(&master)?;
|
||||
let pty = PtyIdentity {
|
||||
master_fd: FileDescriptor::new(master.as_raw_fd()),
|
||||
device: DeviceId::new(stat.st_dev),
|
||||
inode: InodeId::new(stat.st_ino),
|
||||
};
|
||||
Ok((File::from(master), File::from(slave), pty))
|
||||
}
|
||||
|
||||
fn staged_terminal(
|
||||
request: &TerminalAcquisition,
|
||||
pty: PtyIdentity,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<SessionTerminal, TerminalError> {
|
||||
terminal_snapshot(
|
||||
request,
|
||||
SessionTerminalState::Intent,
|
||||
PersistedHostProcessOwnership::committed(request.host_coordinator),
|
||||
Some(pty),
|
||||
PersistedHostProcessOwnership::staged(staged.identity()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn terminal_snapshot(
|
||||
request: &TerminalAcquisition,
|
||||
state: SessionTerminalState,
|
||||
host_coordinator: PersistedHostProcessOwnership,
|
||||
pty_master: Option<PtyIdentity>,
|
||||
exec_client: PersistedHostProcessOwnership,
|
||||
guest_process: Option<GuestProcessIdentity>,
|
||||
) -> Result<SessionTerminal, TerminalError> {
|
||||
SessionTerminal::snapshot(SessionTerminalFields {
|
||||
generation_id: request.generation_id,
|
||||
session_id: request.session_id,
|
||||
state,
|
||||
container_id: (state != SessionTerminalState::Intent || pty_master.is_some())
|
||||
.then_some(request.container_id),
|
||||
host_coordinator,
|
||||
pty_master,
|
||||
exec_client,
|
||||
guest_process,
|
||||
})
|
||||
.map_err(|_| io::Error::other("Session Terminal snapshot is invalid").into())
|
||||
}
|
||||
|
||||
fn copy_terminal_state(
|
||||
record: &SessionTerminal,
|
||||
state: SessionTerminalState,
|
||||
) -> Result<SessionTerminal, TerminalError> {
|
||||
SessionTerminal::snapshot(SessionTerminalFields {
|
||||
generation_id: record.generation_id(),
|
||||
session_id: record.session_id(),
|
||||
state,
|
||||
container_id: record.container_id(),
|
||||
host_coordinator: *record.host_coordinator(),
|
||||
pty_master: record.pty_master(),
|
||||
exec_client: *record.exec_client(),
|
||||
guest_process: record.guest_process(),
|
||||
})
|
||||
.map_err(|_| io::Error::other("Session Terminal release snapshot is invalid").into())
|
||||
}
|
||||
|
||||
fn clean_terminal(
|
||||
generation_id: GenerationId,
|
||||
session_id: SessionId,
|
||||
) -> Result<SessionTerminal, TerminalError> {
|
||||
SessionTerminal::snapshot(SessionTerminalFields {
|
||||
generation_id,
|
||||
session_id,
|
||||
state: SessionTerminalState::Clean,
|
||||
container_id: None,
|
||||
host_coordinator: PersistedHostProcessOwnership::unassigned(),
|
||||
pty_master: None,
|
||||
exec_client: PersistedHostProcessOwnership::unassigned(),
|
||||
guest_process: None,
|
||||
})
|
||||
.map_err(|_| io::Error::other("Session Terminal clean snapshot is invalid").into())
|
||||
}
|
||||
|
||||
fn wait_for_guest_identity(
|
||||
request: &TerminalAcquisition,
|
||||
process: &RetainedProcess,
|
||||
) -> Result<GuestProcessIdentity, TerminalError> {
|
||||
let deadline = Instant::now() + IDENTITY_DEADLINE;
|
||||
while Instant::now() < deadline {
|
||||
if process.has_exited()? {
|
||||
return Err(io::Error::other("exec client exited before guest identity").into());
|
||||
}
|
||||
match std::fs::read_to_string(&request.internal_pid_file) {
|
||||
Ok(value) => {
|
||||
let pid = value
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|pid| *pid > 0)
|
||||
.ok_or_else(|| io::Error::other("runsc internal PID file is malformed"))?;
|
||||
if let Some(identity) = observe_guest_identity(request, pid)? {
|
||||
if identity.leader_pid.get() != identity.process_group_id.get() {
|
||||
return Err(TerminalError::GuestIdentityMismatch);
|
||||
}
|
||||
return Ok(identity);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
Err(io::Error::other("guest identity deadline expired").into())
|
||||
}
|
||||
|
||||
fn observe_guest_identity(
|
||||
request: &TerminalAcquisition,
|
||||
pid: u32,
|
||||
) -> Result<Option<GuestProcessIdentity>, TerminalError> {
|
||||
let stdout = run_guest_identity_query(request, &[OsString::from(pid.to_string())])?;
|
||||
if stdout == b"absent\n" {
|
||||
return Ok(None);
|
||||
}
|
||||
let fields = std::str::from_utf8(&stdout)
|
||||
.ok()
|
||||
.map(str::split_whitespace)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
let [leader, start, group] = fields.as_slice() else {
|
||||
return Err(io::Error::other("guest identity response is malformed").into());
|
||||
};
|
||||
Ok(Some(GuestProcessIdentity {
|
||||
leader_pid: GuestProcessId::new(
|
||||
leader
|
||||
.parse()
|
||||
.map_err(|_| io::Error::other("guest leader PID is malformed"))?,
|
||||
),
|
||||
leader_start_time: GuestProcessStartTimeTicks::new(
|
||||
start
|
||||
.parse()
|
||||
.map_err(|_| io::Error::other("guest start time is malformed"))?,
|
||||
),
|
||||
process_group_id: GuestProcessGroupId::new(
|
||||
group
|
||||
.parse()
|
||||
.map_err(|_| io::Error::other("guest process group is malformed"))?,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
fn observe_group_absent(request: &TerminalAcquisition, group: u32) -> Result<bool, TerminalError> {
|
||||
let stdout = run_guest_identity_query(
|
||||
request,
|
||||
&[OsString::from("--group"), OsString::from(group.to_string())],
|
||||
)?;
|
||||
match stdout.as_slice() {
|
||||
b"absent\n" => Ok(true),
|
||||
b"present\n" => Ok(false),
|
||||
_ => Err(io::Error::other("guest group response is malformed").into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_guest_identity_query(
|
||||
request: &TerminalAcquisition,
|
||||
arguments: &[OsString],
|
||||
) -> Result<Vec<u8>, TerminalError> {
|
||||
let mut target = runsc_target(request);
|
||||
target = target
|
||||
.argument("exec")
|
||||
.argument(hex_lower(request.container_id.as_bytes()))
|
||||
.argument(SANDBOX_INIT_PATH)
|
||||
.argument(PRIVATE_SESSION_IDENTITY_ROLE);
|
||||
for argument in arguments {
|
||||
target = target.argument(argument.clone());
|
||||
}
|
||||
let mut child = child_broker().run_transient_captured(&target)?;
|
||||
let deadline = Instant::now() + IDENTITY_DEADLINE;
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
while Instant::now() < deadline && !child.process().has_exited()? {
|
||||
read_available(child.streams().0, &mut stdout)?;
|
||||
read_available(child.streams().1, &mut stderr)?;
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
if !child.process().has_exited()? {
|
||||
child.process().send_signal(rustix::process::Signal::KILL)?;
|
||||
child_broker().reap_status(child.into_process())?;
|
||||
return Err(io::Error::other("guest identity observation deadline expired").into());
|
||||
}
|
||||
read_available(child.streams().0, &mut stdout)?;
|
||||
read_available(child.streams().1, &mut stderr)?;
|
||||
let exit = child_broker().reap_status(child.into_process())?;
|
||||
if exit.exit_code != Some(0) || exit.terminating_signal.is_some() {
|
||||
let stderr = String::from_utf8_lossy(&stderr);
|
||||
return Err(io::Error::other(format!("guest identity query failed: {stderr}")).into());
|
||||
}
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
fn runsc_target(request: &TerminalAcquisition) -> TargetCommand {
|
||||
request.runsc_global_arguments.iter().cloned().fold(
|
||||
TargetCommand::new(request.runsc_program.clone()),
|
||||
TargetCommand::argument,
|
||||
)
|
||||
}
|
||||
|
||||
fn read_available(file: &mut File, output: &mut Vec<u8>) -> io::Result<()> {
|
||||
let mut bytes = [0_u8; 4096];
|
||||
loop {
|
||||
match file.read(&mut bytes) {
|
||||
Ok(0) => return Ok(()),
|
||||
Ok(length) => output.extend_from_slice(&bytes[..length]),
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_pid_file(path: &std::path::Path) -> io::Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_io(error: &TerminalError) -> io::Error {
|
||||
io::Error::other(error.to_string())
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
bytes.iter().fold(String::new(), |mut output, byte| {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -542,859 +542,6 @@ fn engine_for(fixture: &Fixture) -> (Arc<MockFactory>, SessionTerminalEngine) {
|
|||
engine_for_store(store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
||||
let generation = GenerationIdentity {
|
||||
agent_profile: "trusted".into(),
|
||||
domain_id: DomainId::new(bytes(1)),
|
||||
generation_id: GenerationId::new(7),
|
||||
configuration_fingerprint: ConfigurationFingerprint::new(bytes(2)),
|
||||
coordinator_id: CoordinatorId::new(bytes(3)),
|
||||
sandbox_id: SandboxId::new(bytes(5)),
|
||||
};
|
||||
let mut trust_anchor = TrustAnchor::create(generation.clone()).unwrap();
|
||||
let publication = trust_anchor.publication_identity();
|
||||
let coordinator_key = trust_anchor.coordinator_protocol_key();
|
||||
let credentials = trust_anchor.take_sandbox_credential_bundle().unwrap();
|
||||
let sandbox_key = SigningKey::from_bytes(
|
||||
credentials
|
||||
.sandbox_private_key
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let anchor_key = SigningKey::from_bytes(&bytes(61));
|
||||
let binding = SessionBinding {
|
||||
generation_identity: generation,
|
||||
cli_id: ClientId::new(bytes(62)),
|
||||
session_id: SessionId::new(bytes(63)),
|
||||
session_anchor_public_key: anchor_key.verifying_key().to_bytes().to_vec(),
|
||||
};
|
||||
let capability = issue_session_capability(
|
||||
binding.clone(),
|
||||
credentials.coordinator_protocol_credential().credential_id,
|
||||
credentials.sandbox_protocol_credential().credential_id,
|
||||
&coordinator_key,
|
||||
)
|
||||
.unwrap();
|
||||
let authority = VerifiedSession::new(
|
||||
publication.trust_manifest_sha256,
|
||||
credentials.trust_manifest.tls_ca_sha256,
|
||||
&credentials.trust_manifest,
|
||||
binding.clone(),
|
||||
credentials.coordinator_protocol_credential(),
|
||||
credentials.sandbox_protocol_credential(),
|
||||
capability.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let fixture = Fixture::new(&[]);
|
||||
let session_anchor_id =
|
||||
SessionAnchorId::new(Sha256::digest(&binding.session_anchor_public_key).into());
|
||||
let mut session = Session {
|
||||
generation_id: GenerationId::new(7),
|
||||
session_id: binding.session_id,
|
||||
cli_id: binding.cli_id,
|
||||
session_anchor_id,
|
||||
session_phase: SessionPhase::Registering,
|
||||
};
|
||||
fixture
|
||||
.store
|
||||
.apply(&[DomainStoreMutation::PutSession(session.clone())])
|
||||
.unwrap();
|
||||
session.session_phase = SessionPhase::Attached;
|
||||
fixture
|
||||
.store
|
||||
.apply(&[DomainStoreMutation::PutSession(session)])
|
||||
.unwrap();
|
||||
|
||||
let prompt_transport = Arc::new(SessionPromptTransport::new(Arc::clone(&fixture.store)));
|
||||
let factory = Arc::new(MockFactory::default());
|
||||
let mut engine = SessionTerminalEngine::new(
|
||||
Arc::clone(&fixture.store),
|
||||
factory.clone(),
|
||||
prompt_transport.clone(),
|
||||
);
|
||||
engine.acquire(&acquisition(binding.session_id)).unwrap();
|
||||
let request = MailboxRequest::new(
|
||||
binding.clone(),
|
||||
RequestId::new(bytes(64)),
|
||||
capability,
|
||||
credentials.sandbox_protocol_credential().credential_id,
|
||||
vec![b"/usr/bin/tool".to_vec()],
|
||||
"update host metadata".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let signed_request = encode_signed(&request, &sandbox_key).unwrap();
|
||||
let expected_prompt = prompt_transport
|
||||
.queue_signed_request(authority, &signed_request)
|
||||
.unwrap();
|
||||
let engine = Arc::new(Mutex::new(engine));
|
||||
let mut control = RunscInteractiveControlSession {
|
||||
engine: Arc::clone(&engine),
|
||||
prompt_transport: prompt_transport.clone(),
|
||||
generation_id: GenerationId::new(7),
|
||||
session_id: binding.session_id,
|
||||
};
|
||||
let batch = control
|
||||
.exchange(InteractiveSessionEvent::Poll {
|
||||
session_id: binding.session_id,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
batch.messages,
|
||||
vec![InteractiveSessionMessage::Prompt(expected_prompt.clone())]
|
||||
);
|
||||
let resumed_size = TerminalResize {
|
||||
session_id: binding.session_id,
|
||||
rows: NonZeroU16::new(55).unwrap(),
|
||||
columns: NonZeroU16::new(144).unwrap(),
|
||||
pixel_width: 1_440,
|
||||
pixel_height: 900,
|
||||
};
|
||||
control
|
||||
.exchange(InteractiveSessionEvent::Terminal(
|
||||
SessionTerminalEvent::Resize(resumed_size),
|
||||
))
|
||||
.unwrap();
|
||||
let signed_decision = encode_signed(
|
||||
&DeveloperDecision {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: binding.clone(),
|
||||
request_id: expected_prompt.request_id,
|
||||
request_sha256: expected_prompt.request_sha256,
|
||||
decision: DeveloperDecisionKind::Deny,
|
||||
approved_command_argv: Vec::new(),
|
||||
},
|
||||
&anchor_key,
|
||||
)
|
||||
.unwrap();
|
||||
control
|
||||
.exchange(InteractiveSessionEvent::PromptDecision {
|
||||
session_id: binding.session_id,
|
||||
signed_decision: signed_decision.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let routed = prompt_transport
|
||||
.take_decision(binding.session_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
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!(
|
||||
!factory
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|event| event == "signal:TSTP")
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.sessions
|
||||
.into_iter()
|
||||
.find(|session| session.session_id == binding.session_id)
|
||||
.unwrap()
|
||||
.session_phase,
|
||||
SessionPhase::Attached
|
||||
);
|
||||
engine
|
||||
.lock()
|
||||
.unwrap()
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
session_id: binding.session_id,
|
||||
bytes: b"sandbox-input".to_vec(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn engine_for_store(
|
||||
store: Arc<dyn TerminalDomainStore>,
|
||||
) -> (Arc<MockFactory>, SessionTerminalEngine) {
|
||||
let factory = Arc::new(MockFactory::default());
|
||||
let engine = SessionTerminalEngine {
|
||||
recorder: HostCoordinatorTerminalRecorder::with_store(store),
|
||||
factory: factory.clone(),
|
||||
cancellation: Arc::new(Cancellation(Arc::clone(&factory.log))),
|
||||
active: HashMap::new(),
|
||||
};
|
||||
(factory, engine)
|
||||
}
|
||||
|
||||
fn id(value: u8) -> SessionId {
|
||||
SessionId::new(bytes(value))
|
||||
}
|
||||
|
||||
fn assert_no_group_signal(factory: &MockFactory) {
|
||||
assert!(
|
||||
factory
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|action| !action.starts_with("signal:"))
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_no_guest_action_after(factory: &MockFactory, marker: &str) {
|
||||
let log = factory.log.lock().unwrap();
|
||||
let marker = log.iter().position(|action| action == marker).unwrap();
|
||||
assert!(
|
||||
log[marker + 1..]
|
||||
.iter()
|
||||
.all(|action| !action.starts_with("query:") && !action.starts_with("signal:"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquisition_refuses_authority_outside_the_committed_sandbox() {
|
||||
let session = id(6);
|
||||
let (fixture, _factory, mut engine) = setup(&[session]);
|
||||
let mut request = acquisition(session);
|
||||
request.host_coordinator = process(700);
|
||||
assert!(matches!(
|
||||
engine.acquire(&request),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.session_terminals
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquisition_requires_attached_current_session() {
|
||||
for phase in [
|
||||
SessionPhase::New,
|
||||
SessionPhase::Waiting,
|
||||
SessionPhase::Registering,
|
||||
] {
|
||||
let session = id(6);
|
||||
let fixture = Fixture::new(&[]);
|
||||
fixture.insert_session(session, phase);
|
||||
let (_factory, mut engine) = engine_for(&fixture);
|
||||
assert!(matches!(
|
||||
engine.acquire(&acquisition(session)),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.session_terminals
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_detach_failure_and_sandbox_release_revoke_active_terminal() {
|
||||
for revoke in ["closed", "failed", "sandbox-release"] {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
match revoke {
|
||||
"closed" => fixture.set_session_phase(session, SessionPhase::Closed),
|
||||
"failed" => fixture.set_session_phase(session, SessionPhase::Failed),
|
||||
"sandbox-release" => fixture.release_sandbox(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"refused".to_vec(),
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert!(!engine.contains(session));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_container_hc_and_authority_read_mismatch_use_host_only_release() {
|
||||
for mismatch in ["domain", "container", "host-coordinator"] {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
let request = &mut engine.active.get_mut(&session).unwrap().request;
|
||||
match mismatch {
|
||||
"domain" => request.domain_id = DomainId::new(bytes(80)),
|
||||
"container" => request.container_id = ContainerId::new(bytes(81)),
|
||||
"host-coordinator" => request.host_coordinator = process(800),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"refused".to_vec(),
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
let session = id(6);
|
||||
let fixture = Fixture::new(&[session]);
|
||||
let store = Arc::new(FailingRunningStore::new(Arc::clone(&fixture.store)));
|
||||
let (factory, mut engine) = engine_for_store(store.clone());
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
store.arm_snapshot_failure();
|
||||
assert!(matches!(
|
||||
engine.poll(session, GenerationId::new(7)),
|
||||
Err(TerminalError::Store(DomainStoreError::Io(_)))
|
||||
));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_close_and_loss_revalidate_before_guest_cleanup() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
fixture.release_sandbox();
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
fixture.add_newer_generation();
|
||||
engine
|
||||
.lose(session, GenerationId::new(7), TerminalLoss::PtyLost)
|
||||
.unwrap();
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
let store = Arc::clone(&fixture.store);
|
||||
let log = Arc::clone(&factory.log);
|
||||
factory.state(0).lock().unwrap().group_query_hook = Some((
|
||||
2,
|
||||
Arc::new(move || {
|
||||
log.lock().unwrap().push("revoke:sandbox".into());
|
||||
release_sandbox(&store);
|
||||
}),
|
||||
));
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_no_guest_action_after(&factory, "revoke:sandbox");
|
||||
let state = factory.state(0);
|
||||
assert!(state.lock().unwrap().resources.exec_reaped);
|
||||
assert!(!state.lock().unwrap().resources.pty_open);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
|
||||
let fixture = Fixture::new(&[session]);
|
||||
let store = Arc::new(FailingRunningStore::new(Arc::clone(&fixture.store)));
|
||||
let (factory, mut engine) = engine_for_store(store.clone());
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
let failing_store = Arc::clone(&store);
|
||||
let log = Arc::clone(&factory.log);
|
||||
factory.state(0).lock().unwrap().group_query_hook = Some((
|
||||
2,
|
||||
Arc::new(move || {
|
||||
log.lock().unwrap().push("revoke:read-error".into());
|
||||
failing_store.arm_snapshot_failure();
|
||||
}),
|
||||
));
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_no_guest_action_after(&factory, "revoke:read-error");
|
||||
let state = factory.state(0);
|
||||
assert!(state.lock().unwrap().resources.exec_reaped);
|
||||
assert!(!state.lock().unwrap().resources.pty_open);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(8),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
session_id: session,
|
||||
signal: TerminalSignalKind::Interrupt,
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::StaleGeneration)
|
||||
));
|
||||
assert_no_group_signal(&factory);
|
||||
assert!(!engine.contains(session));
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
fixture.add_newer_generation();
|
||||
assert!(matches!(
|
||||
engine.poll(session, GenerationId::new(7)),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert!(!engine.contains(session));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_and_terminal_signals_use_the_exact_group_map() {
|
||||
let session = id(6);
|
||||
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Resize(TerminalResize {
|
||||
session_id: session,
|
||||
rows: NonZeroU16::new(40).unwrap(),
|
||||
columns: NonZeroU16::new(120).unwrap(),
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}),
|
||||
)
|
||||
.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);
|
||||
}
|
||||
assert_eq!(
|
||||
&factory.log.lock().unwrap()[..2],
|
||||
&["resize:40x120", "signal:WINCH"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_guest_identity_refuses_numeric_group_action() {
|
||||
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(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
session_id: session,
|
||||
signal: TerminalSignalKind::Interrupt,
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::GuestIdentityMismatch)
|
||||
));
|
||||
assert!(factory.log.lock().unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::GuestIdentityMismatch)
|
||||
));
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_leader_with_live_group_retains_release_until_group_is_empty() {
|
||||
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.leader_present = false;
|
||||
state.guest.group_present = true;
|
||||
}
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::GuestIdentityMismatch)
|
||||
));
|
||||
let state = factory.state(0);
|
||||
assert!(state.lock().unwrap().resources.exec_reaped);
|
||||
assert!(!state.lock().unwrap().resources.pty_open);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
|
||||
state.lock().unwrap().guest.group_present = false;
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.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() {
|
||||
for point in [
|
||||
StoreFailurePoint::Snapshot,
|
||||
StoreFailurePoint::Write,
|
||||
StoreFailurePoint::Commit,
|
||||
] {
|
||||
for persistent in [false, true] {
|
||||
let session = id(6);
|
||||
let fixture = Fixture::new(&[session]);
|
||||
let store = Arc::new(FailingRunningStore::new(Arc::clone(&fixture.store)));
|
||||
let recorder = HostCoordinatorTerminalRecorder::with_store(store.clone());
|
||||
let request = acquisition(session);
|
||||
recorder
|
||||
.put(
|
||||
terminal_snapshot(
|
||||
&request,
|
||||
SessionTerminalState::Intent,
|
||||
PersistedHostProcessOwnership::unassigned(),
|
||||
None,
|
||||
PersistedHostProcessOwnership::unassigned(),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let pty = PtyIdentity {
|
||||
master_fd: FileDescriptor::new(9),
|
||||
device: DeviceId::new(10),
|
||||
inode: InodeId::new(11),
|
||||
};
|
||||
recorder
|
||||
.put(
|
||||
staged_terminal(
|
||||
&request,
|
||||
pty,
|
||||
StagedProcessOwnership::v1(
|
||||
request.domain_id,
|
||||
request.generation_id,
|
||||
request.coordinator_id,
|
||||
lazaret_core::records::HostProcessOwnershipSlot::SessionTerminalExecClient(
|
||||
request.session_id,
|
||||
),
|
||||
process(90),
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let guest = GuestProcessIdentity {
|
||||
leader_pid: GuestProcessId::new(41),
|
||||
leader_start_time: GuestProcessStartTimeTicks::new(101),
|
||||
process_group_id: GuestProcessGroupId::new(41),
|
||||
};
|
||||
let running = terminal_snapshot(
|
||||
&request,
|
||||
SessionTerminalState::Running,
|
||||
PersistedHostProcessOwnership::committed(request.host_coordinator),
|
||||
Some(pty),
|
||||
PersistedHostProcessOwnership::committed(process(90)),
|
||||
Some(guest),
|
||||
)
|
||||
.unwrap();
|
||||
let state = Arc::new(Mutex::new(MockState {
|
||||
output: VecDeque::new(),
|
||||
log: Arc::new(Mutex::new(Vec::new())),
|
||||
guest: MockGuestState {
|
||||
group_present: true,
|
||||
leader_present: true,
|
||||
identity_mismatch: false,
|
||||
clears_after_signal: "HUP",
|
||||
},
|
||||
resources: MockResourceState {
|
||||
exec_exited: false,
|
||||
exec_reaped: false,
|
||||
pty_open: true,
|
||||
},
|
||||
group_queries: 0,
|
||||
group_query_hook: None,
|
||||
}));
|
||||
let mut process_handle = MockProcess {
|
||||
state: Arc::clone(&state),
|
||||
};
|
||||
store.arm(point, persistent);
|
||||
let error = commit_running_or_cleanup(
|
||||
&recorder,
|
||||
&request,
|
||||
running,
|
||||
pty,
|
||||
process(90),
|
||||
guest,
|
||||
&mut process_handle,
|
||||
)
|
||||
.unwrap_err();
|
||||
match error {
|
||||
TerminalError::AcquisitionCleanup { cleanup: None, .. } if !persistent => {}
|
||||
TerminalError::AcquisitionCleanup {
|
||||
cleanup: Some(_), ..
|
||||
} if persistent => {}
|
||||
error => panic!("unexpected cleanup result: {error}"),
|
||||
}
|
||||
let state = state.lock().unwrap();
|
||||
assert_eq!(state.guest.group_present, persistent);
|
||||
assert_eq!(
|
||||
state
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.starts_with("signal:")),
|
||||
!persistent
|
||||
);
|
||||
assert!(state.resources.exec_exited);
|
||||
assert!(state.resources.exec_reaped);
|
||||
assert!(!state.resources.pty_open);
|
||||
drop(state);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
if persistent {
|
||||
SessionTerminalState::Intent
|
||||
} else {
|
||||
SessionTerminalState::Clean
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_output_is_ordered_and_prompt_route_isolated_by_session() {
|
||||
let first = id(6);
|
||||
let second = id(7);
|
||||
let (fixture, factory, mut engine) = setup(&[first, second]);
|
||||
engine.acquire(&acquisition(first)).unwrap();
|
||||
engine.acquire(&acquisition(second)).unwrap();
|
||||
factory
|
||||
.state(0)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output
|
||||
.extend([b"one".to_vec(), b"two".to_vec()]);
|
||||
factory
|
||||
.state(1)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output
|
||||
.push_back(b"other".to_vec());
|
||||
fixture.set_session_phase(first, SessionPhase::Prompting);
|
||||
engine
|
||||
.set_route(first, GenerationId::new(7), TerminalRoute::Prompt)
|
||||
.unwrap();
|
||||
assert!(engine.poll(first, GenerationId::new(7)).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
engine.poll(second, GenerationId::new(7)).unwrap(),
|
||||
vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id: second,
|
||||
bytes: b"other".to_vec(),
|
||||
})]
|
||||
);
|
||||
fixture.set_session_phase(first, SessionPhase::Attached);
|
||||
engine
|
||||
.set_route(first, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||
.unwrap();
|
||||
let mut merged = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let messages = engine.poll(first, GenerationId::new(7)).unwrap();
|
||||
let [SessionTerminalMessage::Output(output)] = messages.as_slice() else {
|
||||
panic!("expected one merged output message");
|
||||
};
|
||||
merged.extend_from_slice(&output.bytes);
|
||||
}
|
||||
assert_eq!(merged, b"onetwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
||||
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 = "KILL";
|
||||
factory
|
||||
.state(0)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output
|
||||
.extend((0..10).map(|_| vec![b'x'; MAX_OUTPUT_BYTES]));
|
||||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = messages
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
SessionTerminalMessage::Output(output) => Some(output.bytes.len()),
|
||||
SessionTerminalMessage::Exit(_) => None,
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert_eq!(bytes, MAX_DRAIN_BYTES);
|
||||
assert!(
|
||||
matches!(messages.last(), Some(SessionTerminalMessage::Exit(exit)) if exit.exit_code() == Some(17))
|
||||
);
|
||||
let log = factory.log.lock().unwrap();
|
||||
assert_eq!(log[0], "cancel-route");
|
||||
assert_eq!(
|
||||
log.iter()
|
||||
.filter(|action| action.starts_with("signal:"))
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
["signal:HUP", "signal:TERM", "signal:KILL"]
|
||||
);
|
||||
drop(log);
|
||||
assert!(!engine.contains(session));
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"reattach".to_vec(),
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::UnknownSession)
|
||||
));
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::Clean
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_component_loss_and_hard_hc_loss_never_reattach() {
|
||||
for loss in [
|
||||
TerminalLoss::ClientDisconnected,
|
||||
TerminalLoss::PtyLost,
|
||||
TerminalLoss::ExecClientLost,
|
||||
] {
|
||||
let session = id(6);
|
||||
let (_fixture, _factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
let messages = engine.lose(session, GenerationId::new(7), loss).unwrap();
|
||||
assert_eq!(
|
||||
messages.is_empty(),
|
||||
loss == TerminalLoss::ClientDisconnected
|
||||
);
|
||||
assert!(!engine.contains(session));
|
||||
}
|
||||
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
assert!(
|
||||
engine
|
||||
.lose(
|
||||
session,
|
||||
GenerationId::new(7),
|
||||
TerminalLoss::HostCoordinatorLost,
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!engine.contains(session));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::Running
|
||||
);
|
||||
}
|
||||
include!("tests/prompt.rs");
|
||||
include!("tests/acquisition.rs");
|
||||
include!("tests/output.rs");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,507 @@
|
|||
fn assert_no_guest_action_after(factory: &MockFactory, marker: &str) {
|
||||
let log = factory.log.lock().unwrap();
|
||||
let marker = log.iter().position(|action| action == marker).unwrap();
|
||||
assert!(
|
||||
log[marker + 1..]
|
||||
.iter()
|
||||
.all(|action| !action.starts_with("query:") && !action.starts_with("signal:"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquisition_refuses_authority_outside_the_committed_sandbox() {
|
||||
let session = id(6);
|
||||
let (fixture, _factory, mut engine) = setup(&[session]);
|
||||
let mut request = acquisition(session);
|
||||
request.host_coordinator = process(700);
|
||||
assert!(matches!(
|
||||
engine.acquire(&request),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.session_terminals
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn acquisition_requires_attached_current_session() {
|
||||
for phase in [
|
||||
SessionPhase::New,
|
||||
SessionPhase::Waiting,
|
||||
SessionPhase::Registering,
|
||||
] {
|
||||
let session = id(6);
|
||||
let fixture = Fixture::new(&[]);
|
||||
fixture.insert_session(session, phase);
|
||||
let (_factory, mut engine) = engine_for(&fixture);
|
||||
assert!(matches!(
|
||||
engine.acquire(&acquisition(session)),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.session_terminals
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_detach_failure_and_sandbox_release_revoke_active_terminal() {
|
||||
for revoke in ["closed", "failed", "sandbox-release"] {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
match revoke {
|
||||
"closed" => fixture.set_session_phase(session, SessionPhase::Closed),
|
||||
"failed" => fixture.set_session_phase(session, SessionPhase::Failed),
|
||||
"sandbox-release" => fixture.release_sandbox(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"refused".to_vec(),
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert!(!engine.contains(session));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_container_hc_and_authority_read_mismatch_use_host_only_release() {
|
||||
for mismatch in ["domain", "container", "host-coordinator"] {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
let request = &mut engine.active.get_mut(&session).unwrap().request;
|
||||
match mismatch {
|
||||
"domain" => request.domain_id = DomainId::new(bytes(80)),
|
||||
"container" => request.container_id = ContainerId::new(bytes(81)),
|
||||
"host-coordinator" => request.host_coordinator = process(800),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"refused".to_vec(),
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
let session = id(6);
|
||||
let fixture = Fixture::new(&[session]);
|
||||
let store = Arc::new(FailingRunningStore::new(Arc::clone(&fixture.store)));
|
||||
let (factory, mut engine) = engine_for_store(store.clone());
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
store.arm_snapshot_failure();
|
||||
assert!(matches!(
|
||||
engine.poll(session, GenerationId::new(7)),
|
||||
Err(TerminalError::Store(DomainStoreError::Io(_)))
|
||||
));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_close_and_loss_revalidate_before_guest_cleanup() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
fixture.release_sandbox();
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
fixture.add_newer_generation();
|
||||
engine
|
||||
.lose(session, GenerationId::new(7), TerminalLoss::PtyLost)
|
||||
.unwrap();
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
let store = Arc::clone(&fixture.store);
|
||||
let log = Arc::clone(&factory.log);
|
||||
factory.state(0).lock().unwrap().group_query_hook = Some((
|
||||
2,
|
||||
Arc::new(move || {
|
||||
log.lock().unwrap().push("revoke:sandbox".into());
|
||||
release_sandbox(&store);
|
||||
}),
|
||||
));
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_no_guest_action_after(&factory, "revoke:sandbox");
|
||||
let state = factory.state(0);
|
||||
assert!(state.lock().unwrap().resources.exec_reaped);
|
||||
assert!(!state.lock().unwrap().resources.pty_open);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
|
||||
let fixture = Fixture::new(&[session]);
|
||||
let store = Arc::new(FailingRunningStore::new(Arc::clone(&fixture.store)));
|
||||
let (factory, mut engine) = engine_for_store(store.clone());
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
let failing_store = Arc::clone(&store);
|
||||
let log = Arc::clone(&factory.log);
|
||||
factory.state(0).lock().unwrap().group_query_hook = Some((
|
||||
2,
|
||||
Arc::new(move || {
|
||||
log.lock().unwrap().push("revoke:read-error".into());
|
||||
failing_store.arm_snapshot_failure();
|
||||
}),
|
||||
));
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_no_guest_action_after(&factory, "revoke:read-error");
|
||||
let state = factory.state(0);
|
||||
assert!(state.lock().unwrap().resources.exec_reaped);
|
||||
assert!(!state.lock().unwrap().resources.pty_open);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(8),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
session_id: session,
|
||||
signal: TerminalSignalKind::Interrupt,
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::StaleGeneration)
|
||||
));
|
||||
assert_no_group_signal(&factory);
|
||||
assert!(!engine.contains(session));
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
fixture.add_newer_generation();
|
||||
assert!(matches!(
|
||||
engine.poll(session, GenerationId::new(7)),
|
||||
Err(TerminalError::AuthorityMismatch)
|
||||
));
|
||||
assert!(!engine.contains(session));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_and_terminal_signals_use_the_exact_group_map() {
|
||||
let session = id(6);
|
||||
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Resize(TerminalResize {
|
||||
session_id: session,
|
||||
rows: NonZeroU16::new(40).unwrap(),
|
||||
columns: NonZeroU16::new(120).unwrap(),
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}),
|
||||
)
|
||||
.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);
|
||||
}
|
||||
assert_eq!(
|
||||
&factory.log.lock().unwrap()[..2],
|
||||
&["resize:40x120", "signal:WINCH"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_guest_identity_refuses_numeric_group_action() {
|
||||
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(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
session_id: session,
|
||||
signal: TerminalSignalKind::Interrupt,
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::GuestIdentityMismatch)
|
||||
));
|
||||
assert!(factory.log.lock().unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::GuestIdentityMismatch)
|
||||
));
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_leader_with_live_group_retains_release_until_group_is_empty() {
|
||||
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.leader_present = false;
|
||||
state.guest.group_present = true;
|
||||
}
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::GuestIdentityMismatch)
|
||||
));
|
||||
let state = factory.state(0);
|
||||
assert!(state.lock().unwrap().resources.exec_reaped);
|
||||
assert!(!state.lock().unwrap().resources.pty_open);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::ReleaseIntent
|
||||
);
|
||||
|
||||
state.lock().unwrap().guest.group_present = false;
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.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() {
|
||||
for point in [
|
||||
StoreFailurePoint::Snapshot,
|
||||
StoreFailurePoint::Write,
|
||||
StoreFailurePoint::Commit,
|
||||
] {
|
||||
for persistent in [false, true] {
|
||||
let session = id(6);
|
||||
let fixture = Fixture::new(&[session]);
|
||||
let store = Arc::new(FailingRunningStore::new(Arc::clone(&fixture.store)));
|
||||
let recorder = HostCoordinatorTerminalRecorder::with_store(store.clone());
|
||||
let request = acquisition(session);
|
||||
recorder
|
||||
.put(
|
||||
terminal_snapshot(
|
||||
&request,
|
||||
SessionTerminalState::Intent,
|
||||
PersistedHostProcessOwnership::unassigned(),
|
||||
None,
|
||||
PersistedHostProcessOwnership::unassigned(),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let pty = PtyIdentity {
|
||||
master_fd: FileDescriptor::new(9),
|
||||
device: DeviceId::new(10),
|
||||
inode: InodeId::new(11),
|
||||
};
|
||||
recorder
|
||||
.put(
|
||||
staged_terminal(
|
||||
&request,
|
||||
pty,
|
||||
StagedProcessOwnership::v1(
|
||||
request.domain_id,
|
||||
request.generation_id,
|
||||
request.coordinator_id,
|
||||
lazaret_core::records::HostProcessOwnershipSlot::SessionTerminalExecClient(
|
||||
request.session_id,
|
||||
),
|
||||
process(90),
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let guest = GuestProcessIdentity {
|
||||
leader_pid: GuestProcessId::new(41),
|
||||
leader_start_time: GuestProcessStartTimeTicks::new(101),
|
||||
process_group_id: GuestProcessGroupId::new(41),
|
||||
};
|
||||
let running = terminal_snapshot(
|
||||
&request,
|
||||
SessionTerminalState::Running,
|
||||
PersistedHostProcessOwnership::committed(request.host_coordinator),
|
||||
Some(pty),
|
||||
PersistedHostProcessOwnership::committed(process(90)),
|
||||
Some(guest),
|
||||
)
|
||||
.unwrap();
|
||||
let state = Arc::new(Mutex::new(MockState {
|
||||
output: VecDeque::new(),
|
||||
log: Arc::new(Mutex::new(Vec::new())),
|
||||
guest: MockGuestState {
|
||||
group_present: true,
|
||||
leader_present: true,
|
||||
identity_mismatch: false,
|
||||
clears_after_signal: "HUP",
|
||||
},
|
||||
resources: MockResourceState {
|
||||
exec_exited: false,
|
||||
exec_reaped: false,
|
||||
pty_open: true,
|
||||
},
|
||||
group_queries: 0,
|
||||
group_query_hook: None,
|
||||
}));
|
||||
let mut process_handle = MockProcess {
|
||||
state: Arc::clone(&state),
|
||||
};
|
||||
store.arm(point, persistent);
|
||||
let error = commit_running_or_cleanup(
|
||||
&recorder,
|
||||
&request,
|
||||
running,
|
||||
pty,
|
||||
process(90),
|
||||
guest,
|
||||
&mut process_handle,
|
||||
)
|
||||
.unwrap_err();
|
||||
match error {
|
||||
TerminalError::AcquisitionCleanup { cleanup: None, .. } if !persistent => {}
|
||||
TerminalError::AcquisitionCleanup {
|
||||
cleanup: Some(_), ..
|
||||
} if persistent => {}
|
||||
error => panic!("unexpected cleanup result: {error}"),
|
||||
}
|
||||
let state = state.lock().unwrap();
|
||||
assert_eq!(state.guest.group_present, persistent);
|
||||
assert_eq!(
|
||||
state
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.starts_with("signal:")),
|
||||
!persistent
|
||||
);
|
||||
assert!(state.resources.exec_exited);
|
||||
assert!(state.resources.exec_reaped);
|
||||
assert!(!state.resources.pty_open);
|
||||
drop(state);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
if persistent {
|
||||
SessionTerminalState::Intent
|
||||
} else {
|
||||
SessionTerminalState::Clean
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
142
crates/lazaret-host-coordinator/src/terminal/tests/output.rs
Normal file
142
crates/lazaret-host-coordinator/src/terminal/tests/output.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
#[test]
|
||||
fn merged_output_is_ordered_and_prompt_route_isolated_by_session() {
|
||||
let first = id(6);
|
||||
let second = id(7);
|
||||
let (fixture, factory, mut engine) = setup(&[first, second]);
|
||||
engine.acquire(&acquisition(first)).unwrap();
|
||||
engine.acquire(&acquisition(second)).unwrap();
|
||||
factory
|
||||
.state(0)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output
|
||||
.extend([b"one".to_vec(), b"two".to_vec()]);
|
||||
factory
|
||||
.state(1)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output
|
||||
.push_back(b"other".to_vec());
|
||||
fixture.set_session_phase(first, SessionPhase::Prompting);
|
||||
engine
|
||||
.set_route(first, GenerationId::new(7), TerminalRoute::Prompt)
|
||||
.unwrap();
|
||||
assert!(engine.poll(first, GenerationId::new(7)).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
engine.poll(second, GenerationId::new(7)).unwrap(),
|
||||
vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id: second,
|
||||
bytes: b"other".to_vec(),
|
||||
})]
|
||||
);
|
||||
fixture.set_session_phase(first, SessionPhase::Attached);
|
||||
engine
|
||||
.set_route(first, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||
.unwrap();
|
||||
let mut merged = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let messages = engine.poll(first, GenerationId::new(7)).unwrap();
|
||||
let [SessionTerminalMessage::Output(output)] = messages.as_slice() else {
|
||||
panic!("expected one merged output message");
|
||||
};
|
||||
merged.extend_from_slice(&output.bytes);
|
||||
}
|
||||
assert_eq!(merged, b"onetwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
||||
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 = "KILL";
|
||||
factory
|
||||
.state(0)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output
|
||||
.extend((0..10).map(|_| vec![b'x'; MAX_OUTPUT_BYTES]));
|
||||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = messages
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
SessionTerminalMessage::Output(output) => Some(output.bytes.len()),
|
||||
SessionTerminalMessage::Exit(_) => None,
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert_eq!(bytes, MAX_DRAIN_BYTES);
|
||||
assert!(
|
||||
matches!(messages.last(), Some(SessionTerminalMessage::Exit(exit)) if exit.exit_code() == Some(17))
|
||||
);
|
||||
let log = factory.log.lock().unwrap();
|
||||
assert_eq!(log[0], "cancel-route");
|
||||
assert_eq!(
|
||||
log.iter()
|
||||
.filter(|action| action.starts_with("signal:"))
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
["signal:HUP", "signal:TERM", "signal:KILL"]
|
||||
);
|
||||
drop(log);
|
||||
assert!(!engine.contains(session));
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"reattach".to_vec(),
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::UnknownSession)
|
||||
));
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::Clean
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_component_loss_and_hard_hc_loss_never_reattach() {
|
||||
for loss in [
|
||||
TerminalLoss::ClientDisconnected,
|
||||
TerminalLoss::PtyLost,
|
||||
TerminalLoss::ExecClientLost,
|
||||
] {
|
||||
let session = id(6);
|
||||
let (_fixture, _factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
let messages = engine.lose(session, GenerationId::new(7), loss).unwrap();
|
||||
assert_eq!(
|
||||
messages.is_empty(),
|
||||
loss == TerminalLoss::ClientDisconnected
|
||||
);
|
||||
assert!(!engine.contains(session));
|
||||
}
|
||||
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
assert!(
|
||||
engine
|
||||
.lose(
|
||||
session,
|
||||
GenerationId::new(7),
|
||||
TerminalLoss::HostCoordinatorLost,
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!engine.contains(session));
|
||||
assert_no_group_signal(&factory);
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::Running
|
||||
);
|
||||
}
|
||||
204
crates/lazaret-host-coordinator/src/terminal/tests/prompt.rs
Normal file
204
crates/lazaret-host-coordinator/src/terminal/tests/prompt.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
||||
let generation = GenerationIdentity {
|
||||
agent_profile: "trusted".into(),
|
||||
domain_id: DomainId::new(bytes(1)),
|
||||
generation_id: GenerationId::new(7),
|
||||
configuration_fingerprint: ConfigurationFingerprint::new(bytes(2)),
|
||||
coordinator_id: CoordinatorId::new(bytes(3)),
|
||||
sandbox_id: SandboxId::new(bytes(5)),
|
||||
};
|
||||
let mut trust_anchor = TrustAnchor::create(generation.clone()).unwrap();
|
||||
let publication = trust_anchor.publication_identity();
|
||||
let coordinator_key = trust_anchor.coordinator_protocol_key();
|
||||
let credentials = trust_anchor.take_sandbox_credential_bundle().unwrap();
|
||||
let sandbox_key = SigningKey::from_bytes(
|
||||
credentials
|
||||
.sandbox_private_key
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let anchor_key = SigningKey::from_bytes(&bytes(61));
|
||||
let binding = SessionBinding {
|
||||
generation_identity: generation,
|
||||
cli_id: ClientId::new(bytes(62)),
|
||||
session_id: SessionId::new(bytes(63)),
|
||||
session_anchor_public_key: anchor_key.verifying_key().to_bytes().to_vec(),
|
||||
};
|
||||
let capability = issue_session_capability(
|
||||
binding.clone(),
|
||||
credentials.coordinator_protocol_credential().credential_id,
|
||||
credentials.sandbox_protocol_credential().credential_id,
|
||||
&coordinator_key,
|
||||
)
|
||||
.unwrap();
|
||||
let authority = VerifiedSession::new(
|
||||
publication.trust_manifest_sha256,
|
||||
credentials.trust_manifest.tls_ca_sha256,
|
||||
&credentials.trust_manifest,
|
||||
binding.clone(),
|
||||
credentials.coordinator_protocol_credential(),
|
||||
credentials.sandbox_protocol_credential(),
|
||||
capability.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let fixture = Fixture::new(&[]);
|
||||
let session_anchor_id =
|
||||
SessionAnchorId::new(Sha256::digest(&binding.session_anchor_public_key).into());
|
||||
let mut session = Session {
|
||||
generation_id: GenerationId::new(7),
|
||||
session_id: binding.session_id,
|
||||
cli_id: binding.cli_id,
|
||||
session_anchor_id,
|
||||
session_phase: SessionPhase::Registering,
|
||||
};
|
||||
fixture
|
||||
.store
|
||||
.apply(&[DomainStoreMutation::PutSession(session.clone())])
|
||||
.unwrap();
|
||||
session.session_phase = SessionPhase::Attached;
|
||||
fixture
|
||||
.store
|
||||
.apply(&[DomainStoreMutation::PutSession(session)])
|
||||
.unwrap();
|
||||
|
||||
let prompt_transport = Arc::new(SessionPromptTransport::new(Arc::clone(&fixture.store)));
|
||||
let factory = Arc::new(MockFactory::default());
|
||||
let mut engine = SessionTerminalEngine::new(
|
||||
Arc::clone(&fixture.store),
|
||||
factory.clone(),
|
||||
prompt_transport.clone(),
|
||||
);
|
||||
engine.acquire(&acquisition(binding.session_id)).unwrap();
|
||||
let request = MailboxRequest::new(
|
||||
binding.clone(),
|
||||
RequestId::new(bytes(64)),
|
||||
capability,
|
||||
credentials.sandbox_protocol_credential().credential_id,
|
||||
vec![b"/usr/bin/tool".to_vec()],
|
||||
"update host metadata".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let signed_request = encode_signed(&request, &sandbox_key).unwrap();
|
||||
let expected_prompt = prompt_transport
|
||||
.queue_signed_request(authority, &signed_request)
|
||||
.unwrap();
|
||||
let engine = Arc::new(Mutex::new(engine));
|
||||
let mut control = RunscInteractiveControlSession {
|
||||
engine: Arc::clone(&engine),
|
||||
prompt_transport: prompt_transport.clone(),
|
||||
generation_id: GenerationId::new(7),
|
||||
session_id: binding.session_id,
|
||||
};
|
||||
let batch = control
|
||||
.exchange(InteractiveSessionEvent::Poll {
|
||||
session_id: binding.session_id,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
batch.messages,
|
||||
vec![InteractiveSessionMessage::Prompt(expected_prompt.clone())]
|
||||
);
|
||||
let resumed_size = TerminalResize {
|
||||
session_id: binding.session_id,
|
||||
rows: NonZeroU16::new(55).unwrap(),
|
||||
columns: NonZeroU16::new(144).unwrap(),
|
||||
pixel_width: 1_440,
|
||||
pixel_height: 900,
|
||||
};
|
||||
control
|
||||
.exchange(InteractiveSessionEvent::Terminal(
|
||||
SessionTerminalEvent::Resize(resumed_size),
|
||||
))
|
||||
.unwrap();
|
||||
let signed_decision = encode_signed(
|
||||
&DeveloperDecision {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: binding.clone(),
|
||||
request_id: expected_prompt.request_id,
|
||||
request_sha256: expected_prompt.request_sha256,
|
||||
decision: DeveloperDecisionKind::Deny,
|
||||
approved_command_argv: Vec::new(),
|
||||
},
|
||||
&anchor_key,
|
||||
)
|
||||
.unwrap();
|
||||
control
|
||||
.exchange(InteractiveSessionEvent::PromptDecision {
|
||||
session_id: binding.session_id,
|
||||
signed_decision: signed_decision.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let routed = prompt_transport
|
||||
.take_decision(binding.session_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
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!(
|
||||
!factory
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|event| event == "signal:TSTP")
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.sessions
|
||||
.into_iter()
|
||||
.find(|session| session.session_id == binding.session_id)
|
||||
.unwrap()
|
||||
.session_phase,
|
||||
SessionPhase::Attached
|
||||
);
|
||||
engine
|
||||
.lock()
|
||||
.unwrap()
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
session_id: binding.session_id,
|
||||
bytes: b"sandbox-input".to_vec(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn engine_for_store(
|
||||
store: Arc<dyn TerminalDomainStore>,
|
||||
) -> (Arc<MockFactory>, SessionTerminalEngine) {
|
||||
let factory = Arc::new(MockFactory::default());
|
||||
let engine = SessionTerminalEngine {
|
||||
recorder: HostCoordinatorTerminalRecorder::with_store(store),
|
||||
factory: factory.clone(),
|
||||
cancellation: Arc::new(Cancellation(Arc::clone(&factory.log))),
|
||||
active: HashMap::new(),
|
||||
};
|
||||
(factory, engine)
|
||||
}
|
||||
|
||||
fn id(value: u8) -> SessionId {
|
||||
SessionId::new(bytes(value))
|
||||
}
|
||||
|
||||
fn assert_no_group_signal(factory: &MockFactory) {
|
||||
assert!(
|
||||
factory
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|action| !action.starts_with("signal:"))
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue