Add confined host escalation transport
This commit is contained in:
parent
903ff837d6
commit
407b75b1ff
5 changed files with 2888 additions and 21 deletions
|
|
@ -32,7 +32,13 @@ time.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
lazaret-escalation-client = { version = "=0.0.0", path = "../lazaret-escalation-client" }
|
||||
x509-parser.workspace = true
|
||||
|
||||
[[test]]
|
||||
name = "escalation"
|
||||
path = "tests/escalation.rs"
|
||||
harness = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -289,6 +289,11 @@ impl TrustAnchor {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn coordinator_protocol_key(&self) -> SigningKey {
|
||||
self.coordinator_protocol_key.clone()
|
||||
}
|
||||
|
||||
/// Transfers the sandbox credential package once without exposing coordinator or issuer keys.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
|
|||
941
crates/lazaret-host-coordinator/src/escalation.rs
Normal file
941
crates/lazaret-host-coordinator/src/escalation.rs
Normal file
|
|
@ -0,0 +1,941 @@
|
|||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fmt;
|
||||
use std::io::{self, Read};
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use lazaret_core::configuration::ResolvedDomainConfiguration;
|
||||
use lazaret_core::identity::{RequestDigest, RequestId, SessionId, StreamDigest};
|
||||
use lazaret_core::protocol::{
|
||||
MAX_RESULT_CHUNK_BYTES, ProtocolCodecError, ProtocolValidationError, RequestReplayGuard,
|
||||
ResultSenderWindow, ResultWindowError, VerifiedSession, encode_signed, result_chunk_file_name,
|
||||
result_file_name,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
DeveloperDecisionKind, EscalationOutcome, GenerationIdentity, HostProcessOwnershipSlot,
|
||||
MailboxRequest, MailboxResult, MailboxResultChunk, ProcessOwnershipCommitAck, ProtocolVersion,
|
||||
ResultStream, StagedProcessOwnership,
|
||||
};
|
||||
use lazaret_linux::filesystem::{MailboxEntryIdentity, RegularFileMailbox};
|
||||
use lazaret_linux::process::{
|
||||
CapturedProcess, ProcessOwnershipContext, TargetCommand, child_broker, current_process_identity,
|
||||
};
|
||||
use rustix::fs::{FileType, Mode, OFlags, ResolveFlags, fchmod, fstat, mkdirat, openat, openat2};
|
||||
use rustix::process::Signal;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ApprovalSource {
|
||||
AutoRule,
|
||||
Developer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ApprovalAttribution {
|
||||
pub source: ApprovalSource,
|
||||
pub command_argv: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub struct GenerationMailbox {
|
||||
generation_directory: RegularFileMailbox,
|
||||
inbox: RegularFileMailbox,
|
||||
outbox: RegularFileMailbox,
|
||||
}
|
||||
|
||||
impl GenerationMailbox {
|
||||
/// Creates the fixed generation path beneath an already-confined runtime Domain root.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses links, changed ownership or mode, existing identity, or incomplete publication.
|
||||
pub fn create(
|
||||
runtime_domain_root: impl AsFd,
|
||||
generation: &GenerationIdentity,
|
||||
invoking_uid: u32,
|
||||
) -> Result<Self, EscalationError> {
|
||||
let mailbox =
|
||||
create_private_directory(runtime_domain_root, OsStr::new("mailbox"), invoking_uid)?;
|
||||
let generation_name =
|
||||
lazaret_core::protocol::mailbox_generation_name(generation.generation_id.get());
|
||||
let generation_directory =
|
||||
create_private_directory(&mailbox, &generation_name, invoking_uid)?;
|
||||
let identity = lazaret_core::codec::encode(generation)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
generation_directory.publish(OsStr::new("generation-identity.postcard"), &identity)?;
|
||||
let inbox =
|
||||
create_private_directory(&generation_directory, OsStr::new("inbox"), invoking_uid)?;
|
||||
let outbox =
|
||||
create_private_directory(&generation_directory, OsStr::new("outbox"), invoking_uid)?;
|
||||
Ok(Self {
|
||||
generation_directory,
|
||||
inbox,
|
||||
outbox,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn inbox(&self) -> &RegularFileMailbox {
|
||||
&self.inbox
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn outbox(&self) -> &RegularFileMailbox {
|
||||
&self.outbox
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_directories(self) -> (RegularFileMailbox, RegularFileMailbox) {
|
||||
let _ = self.generation_directory;
|
||||
(self.inbox, self.outbox)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EscalationPhase {
|
||||
AwaitingApproval,
|
||||
Executing,
|
||||
Delivered(EscalationOutcome),
|
||||
}
|
||||
|
||||
pub trait DeveloperExecutableResolver {
|
||||
/// Opens the executable named by element zero and returns authority over that exact object.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses an argv vector that cannot be resolved to one approved executable descriptor.
|
||||
fn resolve(&mut self, command_argv: &[Vec<u8>]) -> io::Result<OwnedFd>;
|
||||
}
|
||||
|
||||
pub struct DirectDeveloperExecutableResolver {
|
||||
root: OwnedFd,
|
||||
}
|
||||
|
||||
fn create_private_directory(
|
||||
parent: impl AsFd,
|
||||
name: &OsStr,
|
||||
invoking_uid: u32,
|
||||
) -> io::Result<RegularFileMailbox> {
|
||||
let created = match mkdirat(parent.as_fd(), name, Mode::RUSR | Mode::WUSR | Mode::XUSR) {
|
||||
Ok(()) => true,
|
||||
Err(rustix::io::Errno::EXIST) => false,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let directory = openat(
|
||||
parent.as_fd(),
|
||||
name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
if created {
|
||||
fchmod(&directory, Mode::RUSR | Mode::WUSR | Mode::XUSR)?;
|
||||
}
|
||||
drop(directory);
|
||||
RegularFileMailbox::openat_private(parent, name, invoking_uid)
|
||||
}
|
||||
|
||||
impl DirectDeveloperExecutableResolver {
|
||||
/// # Errors
|
||||
/// Returns an error when host root authority cannot be retained without following a link.
|
||||
pub fn open() -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
root: openat(
|
||||
rustix::fs::CWD,
|
||||
"/",
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DeveloperExecutableResolver for DirectDeveloperExecutableResolver {
|
||||
fn resolve(&mut self, command_argv: &[Vec<u8>]) -> io::Result<OwnedFd> {
|
||||
let executable = command_argv
|
||||
.first()
|
||||
.ok_or_else(|| io::Error::other("approved command vector is empty"))?;
|
||||
if executable.contains(&0) || !executable.starts_with(b"/") {
|
||||
return Err(io::Error::other(
|
||||
"approved executable is not a NUL-free absolute path",
|
||||
));
|
||||
}
|
||||
let relative = std::ffi::OsStr::from_bytes(&executable[1..]);
|
||||
openat2(
|
||||
&self.root,
|
||||
relative,
|
||||
OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
ResolveFlags::BENEATH | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ExecutionOwnershipRecorder {
|
||||
/// # Errors
|
||||
/// Refuses when exact staged ownership cannot be retained.
|
||||
fn stage(&mut self, ownership: StagedProcessOwnership) -> io::Result<()>;
|
||||
|
||||
/// # Errors
|
||||
/// Refuses missing or mismatched exact ownership acknowledgement.
|
||||
fn acknowledge(
|
||||
&mut self,
|
||||
ownership: StagedProcessOwnership,
|
||||
) -> io::Result<ProcessOwnershipCommitAck>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EscalationError {
|
||||
Io(io::Error),
|
||||
Protocol(ProtocolValidationError),
|
||||
ProtocolCodec(ProtocolCodecError),
|
||||
Window(ResultWindowError),
|
||||
DuplicateSessionRequest,
|
||||
UnknownRequest,
|
||||
InvalidPhase,
|
||||
InvalidApproval,
|
||||
}
|
||||
|
||||
impl fmt::Display for EscalationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => error.fmt(formatter),
|
||||
Self::Protocol(error) => error.fmt(formatter),
|
||||
Self::ProtocolCodec(error) => error.fmt(formatter),
|
||||
Self::Window(error) => error.fmt(formatter),
|
||||
Self::DuplicateSessionRequest => {
|
||||
formatter.write_str("session already has an active escalation request")
|
||||
}
|
||||
Self::UnknownRequest => formatter.write_str("escalation request is unknown"),
|
||||
Self::InvalidPhase => formatter.write_str("operation is invalid in the request phase"),
|
||||
Self::InvalidApproval => {
|
||||
formatter.write_str("developer approval does not authorize an executable vector")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EscalationError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
Self::Protocol(error) => Some(error),
|
||||
Self::ProtocolCodec(error) => Some(error),
|
||||
Self::Window(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for EscalationError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProtocolValidationError> for EscalationError {
|
||||
fn from(error: ProtocolValidationError) -> Self {
|
||||
Self::Protocol(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProtocolCodecError> for EscalationError {
|
||||
fn from(error: ProtocolCodecError) -> Self {
|
||||
Self::ProtocolCodec(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ResultWindowError> for EscalationError {
|
||||
fn from(error: ResultWindowError) -> Self {
|
||||
Self::Window(error)
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestAuthority {
|
||||
session: VerifiedSession,
|
||||
request: MailboxRequest,
|
||||
digest: RequestDigest,
|
||||
admitted_at: Instant,
|
||||
}
|
||||
|
||||
struct PublishedChunk {
|
||||
name: OsString,
|
||||
identity: MailboxEntryIdentity,
|
||||
}
|
||||
|
||||
struct Execution {
|
||||
authority: RequestAuthority,
|
||||
process: CapturedProcess,
|
||||
window: ResultSenderWindow,
|
||||
chunks: BTreeMap<u64, PublishedChunk>,
|
||||
stdout: Sha256,
|
||||
stderr: Sha256,
|
||||
stdout_eof: bool,
|
||||
stderr_eof: bool,
|
||||
acknowledgment_deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
enum ActiveRequest {
|
||||
AwaitingApproval(Box<RequestAuthority>),
|
||||
Executing(Box<Execution>),
|
||||
}
|
||||
|
||||
struct PublishedResult {
|
||||
session_id: SessionId,
|
||||
outcome: EscalationOutcome,
|
||||
name: OsString,
|
||||
identity: MailboxEntryIdentity,
|
||||
}
|
||||
|
||||
pub struct EscalationCoordinator {
|
||||
coordinator_key: SigningKey,
|
||||
outbox: RegularFileMailbox,
|
||||
ownership: ProcessOwnershipContext,
|
||||
invoking_uid: u32,
|
||||
request_timeout: Duration,
|
||||
acknowledgment_timeout: Duration,
|
||||
replay: RequestReplayGuard,
|
||||
active_sessions: BTreeMap<SessionId, RequestId>,
|
||||
active: BTreeMap<RequestId, ActiveRequest>,
|
||||
approvals: BTreeMap<RequestId, ApprovalAttribution>,
|
||||
results: BTreeMap<RequestId, PublishedResult>,
|
||||
}
|
||||
|
||||
impl EscalationCoordinator {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
coordinator_key: SigningKey,
|
||||
outbox: RegularFileMailbox,
|
||||
ownership: ProcessOwnershipContext,
|
||||
invoking_uid: u32,
|
||||
request_timeout: Duration,
|
||||
acknowledgment_timeout: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
coordinator_key,
|
||||
outbox,
|
||||
ownership,
|
||||
invoking_uid,
|
||||
request_timeout,
|
||||
acknowledgment_timeout,
|
||||
replay: RequestReplayGuard::default(),
|
||||
active_sessions: BTreeMap::new(),
|
||||
active: BTreeMap::new(),
|
||||
approvals: BTreeMap::new(),
|
||||
results: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Admits one exact signed request and routes matching configuration rules automatically.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses replay, stale tuples, a second request for one session, or launch failure.
|
||||
pub fn receive_request<R: ExecutionOwnershipRecorder>(
|
||||
&mut self,
|
||||
session: VerifiedSession,
|
||||
signed_request: &[u8],
|
||||
configuration: &ResolvedDomainConfiguration,
|
||||
recorder: &mut R,
|
||||
now: Instant,
|
||||
) -> Result<EscalationPhase, EscalationError> {
|
||||
let authority = self.admit(session, signed_request, now)?;
|
||||
let request_id = authority.request.request_id();
|
||||
for rule in configuration.auto_approval_rules() {
|
||||
if let Ok(executable) = rule.authorize(&authority.request) {
|
||||
let command_argv = authority.request.command_argv().to_vec();
|
||||
return self.start_execution(
|
||||
authority,
|
||||
ApprovalSource::AutoRule,
|
||||
executable,
|
||||
command_argv,
|
||||
recorder,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.active.insert(
|
||||
request_id,
|
||||
ActiveRequest::AwaitingApproval(Box::new(authority)),
|
||||
);
|
||||
Ok(EscalationPhase::AwaitingApproval)
|
||||
}
|
||||
|
||||
/// Admits a request into the developer-decision path without composing prompt UI.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses replay, stale tuples, or a second request for one session.
|
||||
pub fn receive_for_developer_approval(
|
||||
&mut self,
|
||||
session: VerifiedSession,
|
||||
signed_request: &[u8],
|
||||
now: Instant,
|
||||
) -> Result<EscalationPhase, EscalationError> {
|
||||
let authority = self.admit(session, signed_request, now)?;
|
||||
let request_id = authority.request.request_id();
|
||||
self.active.insert(
|
||||
request_id,
|
||||
ActiveRequest::AwaitingApproval(Box::new(authority)),
|
||||
);
|
||||
Ok(EscalationPhase::AwaitingApproval)
|
||||
}
|
||||
|
||||
/// Admits an exact request after an external rule matcher supplies its already-open descriptor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses changed argv, replay, stale tuples, duplicate session activity, or launch failure.
|
||||
pub fn receive_auto_approved<R: ExecutionOwnershipRecorder>(
|
||||
&mut self,
|
||||
session: VerifiedSession,
|
||||
signed_request: &[u8],
|
||||
executable: OwnedFd,
|
||||
command_argv: Vec<Vec<u8>>,
|
||||
recorder: &mut R,
|
||||
now: Instant,
|
||||
) -> Result<EscalationPhase, EscalationError> {
|
||||
let authority = self.admit(session, signed_request, now)?;
|
||||
if authority.request.command_argv() != command_argv {
|
||||
let session_id = authority.request.session_binding().session_id;
|
||||
self.active_sessions.remove(&session_id);
|
||||
return Err(EscalationError::InvalidApproval);
|
||||
}
|
||||
self.start_execution(
|
||||
authority,
|
||||
ApprovalSource::AutoRule,
|
||||
executable,
|
||||
command_argv,
|
||||
recorder,
|
||||
)
|
||||
}
|
||||
|
||||
fn admit(
|
||||
&mut self,
|
||||
session: VerifiedSession,
|
||||
signed_request: &[u8],
|
||||
now: Instant,
|
||||
) -> Result<RequestAuthority, EscalationError> {
|
||||
let (request, _) = session.verify_request(signed_request)?;
|
||||
let session_id = request.session_binding().session_id;
|
||||
if self.active_sessions.contains_key(&session_id) {
|
||||
return Err(EscalationError::DuplicateSessionRequest);
|
||||
}
|
||||
let (request, digest) = self.replay.admit(&session, signed_request)?;
|
||||
let request_id = request.request_id();
|
||||
self.active_sessions.insert(session_id, request_id);
|
||||
Ok(RequestAuthority {
|
||||
session,
|
||||
request,
|
||||
digest,
|
||||
admitted_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn approval_source(&self, request_id: RequestId) -> Option<ApprovalSource> {
|
||||
self.approvals
|
||||
.get(&request_id)
|
||||
.map(|attribution| attribution.source)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn approval_attribution(&self, request_id: RequestId) -> Option<&ApprovalAttribution> {
|
||||
self.approvals.get(&request_id)
|
||||
}
|
||||
|
||||
/// Applies one exact Session Anchor decision without providing any prompt or PTY UI.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses stale, mismatched, unsigned, or phase-inconsistent decisions.
|
||||
pub fn apply_developer_decision<Resolver, Recorder>(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
signed_decision: &[u8],
|
||||
resolver: &mut Resolver,
|
||||
recorder: &mut Recorder,
|
||||
) -> Result<EscalationPhase, EscalationError>
|
||||
where
|
||||
Resolver: DeveloperExecutableResolver,
|
||||
Recorder: ExecutionOwnershipRecorder,
|
||||
{
|
||||
let Some(ActiveRequest::AwaitingApproval(authority)) = self.active.get(&request_id) else {
|
||||
return Err(EscalationError::InvalidPhase);
|
||||
};
|
||||
let decision = authority.session.verify_developer_decision(
|
||||
signed_decision,
|
||||
request_id,
|
||||
authority.digest,
|
||||
)?;
|
||||
let executable = match decision.decision {
|
||||
DeveloperDecisionKind::Deny => None,
|
||||
DeveloperDecisionKind::Approve => {
|
||||
if decision.approved_command_argv.is_empty() {
|
||||
return Err(EscalationError::InvalidApproval);
|
||||
}
|
||||
let executable = resolver.resolve(&decision.approved_command_argv)?;
|
||||
validate_executable(executable.as_fd(), self.invoking_uid)?;
|
||||
Some(executable)
|
||||
}
|
||||
};
|
||||
let Some(ActiveRequest::AwaitingApproval(authority)) = self.active.remove(&request_id)
|
||||
else {
|
||||
return Err(EscalationError::InvalidPhase);
|
||||
};
|
||||
match decision.decision {
|
||||
DeveloperDecisionKind::Deny => {
|
||||
self.finish(&authority, EscalationOutcome::Denied, None)?;
|
||||
Ok(EscalationPhase::Delivered(EscalationOutcome::Denied))
|
||||
}
|
||||
DeveloperDecisionKind::Approve => self.start_execution(
|
||||
*authority,
|
||||
ApprovalSource::Developer,
|
||||
executable.ok_or(EscalationError::InvalidApproval)?,
|
||||
decision.approved_command_argv,
|
||||
recorder,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_execution<R: ExecutionOwnershipRecorder>(
|
||||
&mut self,
|
||||
authority: RequestAuthority,
|
||||
approval_source: ApprovalSource,
|
||||
executable: OwnedFd,
|
||||
command_argv: Vec<Vec<u8>>,
|
||||
recorder: &mut R,
|
||||
) -> Result<EscalationPhase, EscalationError> {
|
||||
let request_id = authority.request.request_id();
|
||||
self.approvals.insert(
|
||||
request_id,
|
||||
ApprovalAttribution {
|
||||
source: approval_source,
|
||||
command_argv: command_argv.clone(),
|
||||
},
|
||||
);
|
||||
let setup = (|| {
|
||||
validate_executable(executable.as_fd(), self.invoking_uid)?;
|
||||
let arguments = command_argv
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
.map(OsString::from_vec)
|
||||
.collect::<Vec<_>>();
|
||||
let target = TargetCommand::from_executable(executable, arguments)?;
|
||||
let parent = current_process_identity()?;
|
||||
Ok::<_, io::Error>((target, parent))
|
||||
})();
|
||||
let Ok((target, parent)) = setup else {
|
||||
self.finish(&authority, EscalationOutcome::Failed, None)?;
|
||||
return Ok(EscalationPhase::Delivered(EscalationOutcome::Failed));
|
||||
};
|
||||
let recorder = Rc::new(RefCell::new(recorder));
|
||||
let stage_recorder = Rc::clone(&recorder);
|
||||
let process = child_broker().acquire_captured(
|
||||
parent,
|
||||
ProcessOwnershipContext {
|
||||
slot: HostProcessOwnershipSlot::EscalationHostProcess(request_id),
|
||||
..self.ownership
|
||||
},
|
||||
&target,
|
||||
move |staged| stage_recorder.borrow_mut().stage(staged),
|
||||
move |staged| recorder.borrow_mut().acknowledge(staged),
|
||||
);
|
||||
let Ok(process) = process else {
|
||||
self.finish(&authority, EscalationOutcome::Failed, None)?;
|
||||
return Ok(EscalationPhase::Delivered(EscalationOutcome::Failed));
|
||||
};
|
||||
self.active.insert(
|
||||
request_id,
|
||||
ActiveRequest::Executing(Box::new(Execution {
|
||||
authority,
|
||||
process,
|
||||
window: ResultSenderWindow::default(),
|
||||
chunks: BTreeMap::new(),
|
||||
stdout: Sha256::new(),
|
||||
stderr: Sha256::new(),
|
||||
stdout_eof: false,
|
||||
stderr_eof: false,
|
||||
acknowledgment_deadline: None,
|
||||
})),
|
||||
);
|
||||
Ok(EscalationPhase::Executing)
|
||||
}
|
||||
|
||||
/// Advances nonblocking output while preserving the eight-chunk kernel backpressure bound.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error when exact output publication, process observation, or reaping fails.
|
||||
pub fn progress(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
now: Instant,
|
||||
) -> Result<EscalationPhase, EscalationError> {
|
||||
let Some(active) = self.active.remove(&request_id) else {
|
||||
return self
|
||||
.results
|
||||
.get(&request_id)
|
||||
.map(|result| EscalationPhase::Delivered(result.outcome))
|
||||
.ok_or(EscalationError::UnknownRequest);
|
||||
};
|
||||
let ActiveRequest::Executing(mut execution) = active else {
|
||||
self.active.insert(request_id, active);
|
||||
return Ok(EscalationPhase::AwaitingApproval);
|
||||
};
|
||||
let progress = self.read_available(&mut execution, now);
|
||||
if progress.is_err() {
|
||||
let authority = self.terminate(*execution)?;
|
||||
self.finish(&authority, EscalationOutcome::Failed, None)?;
|
||||
return Ok(EscalationPhase::Delivered(EscalationOutcome::Failed));
|
||||
}
|
||||
if execution.process.process().has_exited()?
|
||||
&& execution.stdout_eof
|
||||
&& execution.stderr_eof
|
||||
&& execution.window.outstanding() == 0
|
||||
{
|
||||
let execution = *execution;
|
||||
let authority = execution.authority;
|
||||
let stdout = StreamDigest::new(execution.stdout.finalize().into());
|
||||
let stderr = StreamDigest::new(execution.stderr.finalize().into());
|
||||
let chunk_count = execution.window.sent_count();
|
||||
let status = child_broker().reap_status(execution.process.into_process())?;
|
||||
self.finish_with_streams(
|
||||
&authority,
|
||||
EscalationOutcome::Completed,
|
||||
status.exit_code,
|
||||
chunk_count,
|
||||
stdout,
|
||||
stderr,
|
||||
)?;
|
||||
return Ok(EscalationPhase::Delivered(EscalationOutcome::Completed));
|
||||
}
|
||||
self.active
|
||||
.insert(request_id, ActiveRequest::Executing(execution));
|
||||
Ok(EscalationPhase::Executing)
|
||||
}
|
||||
|
||||
fn read_available(
|
||||
&mut self,
|
||||
execution: &mut Execution,
|
||||
now: Instant,
|
||||
) -> Result<(), EscalationError> {
|
||||
while execution.window.outstanding() < 8 {
|
||||
let mut made_progress = false;
|
||||
for stream in [ResultStream::Stdout, ResultStream::Stderr] {
|
||||
if execution.window.outstanding() >= 8 {
|
||||
break;
|
||||
}
|
||||
let eof = match stream {
|
||||
ResultStream::Stdout => &mut execution.stdout_eof,
|
||||
ResultStream::Stderr => &mut execution.stderr_eof,
|
||||
};
|
||||
if *eof {
|
||||
continue;
|
||||
}
|
||||
let (stdout, stderr) = execution.process.streams();
|
||||
let reader = match stream {
|
||||
ResultStream::Stdout => stdout,
|
||||
ResultStream::Stderr => stderr,
|
||||
};
|
||||
let mut bytes = vec![0_u8; MAX_RESULT_CHUNK_BYTES];
|
||||
match reader.read(&mut bytes) {
|
||||
Ok(0) => {
|
||||
*eof = true;
|
||||
made_progress = true;
|
||||
}
|
||||
Ok(length) => {
|
||||
bytes.truncate(length);
|
||||
let sequence = execution.window.reserve(length)?;
|
||||
let chunk = MailboxResultChunk {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: execution.authority.session.session_binding().clone(),
|
||||
request_id: execution.authority.request.request_id(),
|
||||
request_sha256: execution.authority.digest,
|
||||
coordinator_credential_id: execution
|
||||
.authority
|
||||
.session
|
||||
.coordinator_credential_id(),
|
||||
sequence,
|
||||
stream,
|
||||
bytes,
|
||||
};
|
||||
match stream {
|
||||
ResultStream::Stdout => execution.stdout.update(&chunk.bytes),
|
||||
ResultStream::Stderr => execution.stderr.update(&chunk.bytes),
|
||||
}
|
||||
let name = result_chunk_file_name(chunk.request_id, sequence);
|
||||
let encoded = encode_signed(&chunk, &self.coordinator_key)?;
|
||||
let identity = self.outbox.publish(&name, &encoded)?;
|
||||
execution
|
||||
.chunks
|
||||
.insert(sequence, PublishedChunk { name, identity });
|
||||
if execution.acknowledgment_deadline.is_none() {
|
||||
execution.acknowledgment_deadline =
|
||||
Some(now + self.acknowledgment_timeout);
|
||||
}
|
||||
made_progress = true;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
if !made_progress {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accepts a highest-contiguous signed acknowledgement and removes only matching chunks.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses tuple mismatch, replay, regression, or acknowledgement beyond sent output.
|
||||
pub fn acknowledge(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
signed_acknowledgment: &[u8],
|
||||
now: Instant,
|
||||
) -> Result<(), EscalationError> {
|
||||
let Some(ActiveRequest::Executing(execution)) = self.active.get_mut(&request_id) else {
|
||||
return Err(EscalationError::InvalidPhase);
|
||||
};
|
||||
let ack = execution.authority.session.verify_result_ack(
|
||||
signed_acknowledgment,
|
||||
request_id,
|
||||
execution.authority.digest,
|
||||
)?;
|
||||
execution
|
||||
.window
|
||||
.acknowledge(ack.highest_contiguous_sequence)?;
|
||||
let acknowledged = execution
|
||||
.chunks
|
||||
.range(..=ack.highest_contiguous_sequence)
|
||||
.map(|(sequence, _)| *sequence)
|
||||
.collect::<Vec<_>>();
|
||||
for sequence in acknowledged {
|
||||
if let Some(chunk) = execution.chunks.get(&sequence) {
|
||||
self.outbox.remove_if_matches(&chunk.name, chunk.identity)?;
|
||||
execution.chunks.remove(&sequence);
|
||||
}
|
||||
}
|
||||
execution.acknowledgment_deadline = if execution.window.outstanding() == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(now + self.acknowledgment_timeout)
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancels an exact live request after verifying the sandbox signature and complete tuple.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses unknown, terminal, unsigned, or mismatched cancellations.
|
||||
pub fn cancel(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
signed_cancellation: &[u8],
|
||||
) -> Result<EscalationPhase, EscalationError> {
|
||||
let Some(active) = self.active.get(&request_id) else {
|
||||
return Err(EscalationError::UnknownRequest);
|
||||
};
|
||||
match active {
|
||||
ActiveRequest::AwaitingApproval(authority) => {
|
||||
authority
|
||||
.session
|
||||
.verify_cancellation(signed_cancellation, request_id)?;
|
||||
}
|
||||
ActiveRequest::Executing(execution) => {
|
||||
execution
|
||||
.authority
|
||||
.session
|
||||
.verify_cancellation(signed_cancellation, request_id)?;
|
||||
}
|
||||
}
|
||||
let active = self
|
||||
.active
|
||||
.remove(&request_id)
|
||||
.ok_or(EscalationError::UnknownRequest)?;
|
||||
let authority = match active {
|
||||
ActiveRequest::AwaitingApproval(authority) => *authority,
|
||||
ActiveRequest::Executing(execution) => self.terminate(*execution)?,
|
||||
};
|
||||
self.finish(&authority, EscalationOutcome::Cancelled, None)?;
|
||||
Ok(EscalationPhase::Delivered(EscalationOutcome::Cancelled))
|
||||
}
|
||||
|
||||
/// Enforces request and acknowledgement deadlines with exact pidfd termination and reaping.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error when exact cleanup or terminal publication fails.
|
||||
pub fn expire(&mut self, now: Instant) -> Result<Vec<RequestId>, EscalationError> {
|
||||
let expired = self
|
||||
.active
|
||||
.iter()
|
||||
.filter_map(|(request_id, active)| {
|
||||
let expired = match active {
|
||||
ActiveRequest::AwaitingApproval(authority) => {
|
||||
now.saturating_duration_since(authority.admitted_at) >= self.request_timeout
|
||||
}
|
||||
ActiveRequest::Executing(execution) => {
|
||||
now.saturating_duration_since(execution.authority.admitted_at)
|
||||
>= self.request_timeout
|
||||
|| execution
|
||||
.acknowledgment_deadline
|
||||
.is_some_and(|deadline| now >= deadline)
|
||||
}
|
||||
};
|
||||
expired.then_some(*request_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for request_id in &expired {
|
||||
let active = self
|
||||
.active
|
||||
.remove(request_id)
|
||||
.ok_or(EscalationError::UnknownRequest)?;
|
||||
let authority = match active {
|
||||
ActiveRequest::AwaitingApproval(authority) => *authority,
|
||||
ActiveRequest::Executing(execution) => self.terminate(*execution)?,
|
||||
};
|
||||
self.finish(&authority, EscalationOutcome::TimedOut, None)?;
|
||||
}
|
||||
Ok(expired)
|
||||
}
|
||||
|
||||
/// Removes all exact result files and exact-child authority for a disconnected session.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error when exact cleanup cannot complete.
|
||||
pub fn session_lost(&mut self, session_id: SessionId) -> Result<(), EscalationError> {
|
||||
if let Some(request_id) = self.active_sessions.remove(&session_id)
|
||||
&& let Some(active) = self.active.remove(&request_id)
|
||||
{
|
||||
match active {
|
||||
ActiveRequest::AwaitingApproval(_) => {}
|
||||
ActiveRequest::Executing(execution) => {
|
||||
self.terminate(*execution)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let result_ids = self
|
||||
.results
|
||||
.iter()
|
||||
.filter_map(|(request_id, result)| {
|
||||
(result.session_id == session_id).then_some(*request_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for request_id in result_ids {
|
||||
if let Some(result) = self.results.remove(&request_id) {
|
||||
self.outbox
|
||||
.remove_if_matches(&result.name, result.identity)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn terminate(&mut self, execution: Execution) -> Result<RequestAuthority, EscalationError> {
|
||||
execution.process.process().send_signal(Signal::KILL)?;
|
||||
let authority = execution.authority;
|
||||
child_broker().reap(execution.process.into_process())?;
|
||||
for chunk in execution.chunks.into_values() {
|
||||
self.outbox.remove_if_matches(&chunk.name, chunk.identity)?;
|
||||
}
|
||||
Ok(authority)
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&mut self,
|
||||
authority: &RequestAuthority,
|
||||
outcome: EscalationOutcome,
|
||||
exit_code: Option<i32>,
|
||||
) -> Result<(), EscalationError> {
|
||||
self.finish_with_streams(
|
||||
authority,
|
||||
outcome,
|
||||
exit_code,
|
||||
0,
|
||||
StreamDigest::new(Sha256::digest([]).into()),
|
||||
StreamDigest::new(Sha256::digest([]).into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn finish_with_streams(
|
||||
&mut self,
|
||||
authority: &RequestAuthority,
|
||||
outcome: EscalationOutcome,
|
||||
exit_code: Option<i32>,
|
||||
chunk_count: u64,
|
||||
stdout_sha256: StreamDigest,
|
||||
stderr_sha256: StreamDigest,
|
||||
) -> Result<(), EscalationError> {
|
||||
let request_id = authority.request.request_id();
|
||||
let session_id = authority.request.session_binding().session_id;
|
||||
let result = MailboxResult {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: authority.session.session_binding().clone(),
|
||||
request_id,
|
||||
request_sha256: authority.digest,
|
||||
coordinator_credential_id: authority.session.coordinator_credential_id(),
|
||||
outcome,
|
||||
exit_code,
|
||||
chunk_count,
|
||||
stdout_sha256,
|
||||
stderr_sha256,
|
||||
};
|
||||
let encoded = encode_signed(&result, &self.coordinator_key)?;
|
||||
let name = result_file_name(request_id);
|
||||
let identity = self.outbox.publish(&name, &encoded)?;
|
||||
self.active_sessions.remove(&session_id);
|
||||
self.results.insert(
|
||||
request_id,
|
||||
PublishedResult {
|
||||
session_id,
|
||||
outcome,
|
||||
name,
|
||||
identity,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_executable(executable: impl AsFd, invoking_uid: u32) -> io::Result<()> {
|
||||
let stat = fstat(executable)?;
|
||||
let file_type = FileType::from_raw_mode(stat.st_mode);
|
||||
let mode = Mode::from_raw_mode(stat.st_mode);
|
||||
if !file_type.is_file()
|
||||
|| stat.st_nlink != 1
|
||||
|| (stat.st_uid != 0 && stat.st_uid != invoking_uid)
|
||||
|| mode.intersects(Mode::WGRP | Mode::WOTH)
|
||||
|| !mode.contains(Mode::XUSR)
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"approved executable changed type, links, ownership, mode, or executability",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LocalExecutionOwnership {
|
||||
staged: Vec<StagedProcessOwnership>,
|
||||
}
|
||||
|
||||
impl ExecutionOwnershipRecorder for LocalExecutionOwnership {
|
||||
fn stage(&mut self, ownership: StagedProcessOwnership) -> io::Result<()> {
|
||||
if self.staged.contains(&ownership) {
|
||||
Err(io::Error::other("execution ownership was already staged"))
|
||||
} else {
|
||||
self.staged.push(ownership);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn acknowledge(
|
||||
&mut self,
|
||||
ownership: StagedProcessOwnership,
|
||||
) -> io::Result<ProcessOwnershipCommitAck> {
|
||||
if let Some(index) = self.staged.iter().position(|staged| *staged == ownership) {
|
||||
self.staged.swap_remove(index);
|
||||
Ok(ProcessOwnershipCommitAck::v1(ownership))
|
||||
} else {
|
||||
Err(io::Error::other("execution ownership is not staged"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,5 +2,6 @@
|
|||
|
||||
pub mod control;
|
||||
pub mod domain_store;
|
||||
pub mod escalation;
|
||||
pub mod runtime;
|
||||
pub mod state_anchor;
|
||||
|
|
|
|||
794
crates/lazaret-host-coordinator/tests/escalation.rs
Normal file
794
crates/lazaret-host-coordinator/tests/escalation.rs
Normal file
|
|
@ -0,0 +1,794 @@
|
|||
use std::error::Error;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::os::fd::AsFd;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::fs::{MetadataExt, symlink};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use lazaret_core::identity::{
|
||||
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, RequestId,
|
||||
SandboxId, SessionId, StreamDigest,
|
||||
};
|
||||
use lazaret_core::protocol::{
|
||||
MAX_SIGNED_RECORD_BYTES, ProtocolValidationError, VerifiedSession, acknowledgment_file_name,
|
||||
encode_signed, issue_session_capability, request_file_name,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
DeveloperDecision, DeveloperDecisionKind, EscalationOutcome, GenerationIdentity,
|
||||
HostProcessOwnershipSlot, MailboxCancellation, MailboxResult, ProcessOwnershipCommitAck,
|
||||
ProtocolVersion, SessionBinding, StagedProcessOwnership,
|
||||
};
|
||||
use lazaret_escalation_client::{ClientEvent, ClientRequest, EscalationClient};
|
||||
use lazaret_host_coordinator::control::TrustAnchor;
|
||||
use lazaret_host_coordinator::escalation::{
|
||||
ApprovalSource, DeveloperExecutableResolver, DirectDeveloperExecutableResolver,
|
||||
EscalationCoordinator, EscalationError, EscalationPhase, ExecutionOwnershipRecorder,
|
||||
GenerationMailbox,
|
||||
};
|
||||
use lazaret_linux::filesystem::RegularFileMailbox;
|
||||
use lazaret_linux::process::{
|
||||
ProcessOwnershipContext, dispatch_private_process_role, recover_process,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn create(name: &str) -> TestResult<Self> {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"lazaret-escalation-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
Instant::now().elapsed().as_nanos()
|
||||
));
|
||||
std::fs::create_dir(&path)?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
|
||||
fn child(&self, name: &str) -> TestResult<PathBuf> {
|
||||
let path = self.0.join(name);
|
||||
std::fs::create_dir(&path)?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
session: VerifiedSession,
|
||||
sandbox_key: SigningKey,
|
||||
coordinator_key: SigningKey,
|
||||
anchor_key: SigningKey,
|
||||
generation: GenerationIdentity,
|
||||
}
|
||||
|
||||
fn repeated(value: u8) -> [u8; 32] {
|
||||
[value; 32]
|
||||
}
|
||||
|
||||
fn make_fixture(seed: u8) -> TestResult<Fixture> {
|
||||
let generation = GenerationIdentity {
|
||||
agent_profile: format!("profile-{seed}"),
|
||||
domain_id: DomainId::new(repeated(seed)),
|
||||
generation_id: GenerationId::new(u64::from(seed) + 1),
|
||||
configuration_fingerprint: ConfigurationFingerprint::new(repeated(seed.wrapping_add(1))),
|
||||
coordinator_id: CoordinatorId::new(repeated(seed.wrapping_add(2))),
|
||||
sandbox_id: SandboxId::new(repeated(seed.wrapping_add(3))),
|
||||
};
|
||||
let mut anchor = TrustAnchor::create(generation.clone())?;
|
||||
let publication = anchor.publication_identity();
|
||||
let coordinator_key = anchor.coordinator_protocol_key();
|
||||
let bundle = anchor.take_sandbox_credential_bundle()?;
|
||||
let sandbox_bytes: [u8; 32] = bundle.sandbox_private_key.as_slice().try_into()?;
|
||||
let sandbox_key = SigningKey::from_bytes(&sandbox_bytes);
|
||||
let anchor_key = SigningKey::from_bytes(&repeated(seed.wrapping_add(4)));
|
||||
let binding = SessionBinding {
|
||||
generation_identity: generation.clone(),
|
||||
cli_id: ClientId::new(repeated(seed.wrapping_add(5))),
|
||||
session_id: SessionId::new(repeated(seed.wrapping_add(6))),
|
||||
session_anchor_public_key: anchor_key.verifying_key().to_bytes().to_vec(),
|
||||
};
|
||||
let capability = issue_session_capability(
|
||||
binding.clone(),
|
||||
bundle.coordinator_protocol_credential().credential_id,
|
||||
bundle.sandbox_protocol_credential().credential_id,
|
||||
&coordinator_key,
|
||||
)?;
|
||||
let session = VerifiedSession::new(
|
||||
publication.trust_manifest_sha256,
|
||||
bundle.trust_manifest.tls_ca_sha256,
|
||||
&bundle.trust_manifest,
|
||||
binding,
|
||||
bundle.coordinator_protocol_credential(),
|
||||
bundle.sandbox_protocol_credential(),
|
||||
capability,
|
||||
)?;
|
||||
Ok(Fixture {
|
||||
session,
|
||||
sandbox_key,
|
||||
coordinator_key,
|
||||
anchor_key,
|
||||
generation,
|
||||
})
|
||||
}
|
||||
|
||||
struct Mailboxes {
|
||||
root: TestDirectory,
|
||||
inbox_path: PathBuf,
|
||||
outbox_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Mailboxes {
|
||||
fn create(name: &str) -> TestResult<Self> {
|
||||
let root = TestDirectory::create(name)?;
|
||||
let inbox_path = root.child("inbox")?;
|
||||
let outbox_path = root.child("outbox")?;
|
||||
Ok(Self {
|
||||
root,
|
||||
inbox_path,
|
||||
outbox_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn client(&self, fixture: &Fixture) -> TestResult<EscalationClient> {
|
||||
Ok(EscalationClient::new(
|
||||
RegularFileMailbox::open(&self.inbox_path)?,
|
||||
RegularFileMailbox::open(&self.outbox_path)?,
|
||||
fixture.session.clone(),
|
||||
fixture.sandbox_key.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
fn coordinator(
|
||||
&self,
|
||||
fixture: &Fixture,
|
||||
request_timeout: Duration,
|
||||
acknowledgment_timeout: Duration,
|
||||
) -> TestResult<EscalationCoordinator> {
|
||||
Ok(EscalationCoordinator::new(
|
||||
fixture.coordinator_key.clone(),
|
||||
RegularFileMailbox::open(&self.outbox_path)?,
|
||||
ProcessOwnershipContext {
|
||||
domain_id: fixture.generation.domain_id,
|
||||
generation_id: fixture.generation.generation_id,
|
||||
coordinator_id: fixture.generation.coordinator_id,
|
||||
slot: HostProcessOwnershipSlot::EscalationHostProcess(RequestId::new([0; 32])),
|
||||
},
|
||||
rustix::process::getuid().as_raw(),
|
||||
request_timeout,
|
||||
acknowledgment_timeout,
|
||||
))
|
||||
}
|
||||
|
||||
fn consume_request(&self, request_id: RequestId) -> TestResult<Vec<u8>> {
|
||||
Ok(RegularFileMailbox::open(&self.inbox_path)?
|
||||
.consume(&request_file_name(request_id), MAX_SIGNED_RECORD_BYTES)?)
|
||||
}
|
||||
|
||||
fn consume_ack(&self, request_id: RequestId, sequence: u64) -> TestResult<Vec<u8>> {
|
||||
Ok(RegularFileMailbox::open(&self.inbox_path)?.consume(
|
||||
&acknowledgment_file_name(request_id, sequence),
|
||||
MAX_SIGNED_RECORD_BYTES,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn result_chunk_count(&self) -> TestResult<usize> {
|
||||
Ok(RegularFileMailbox::open(&self.outbox_path)?
|
||||
.names()?
|
||||
.iter()
|
||||
.filter(|name| name.as_encoded_bytes().starts_with(b"result-chunk-"))
|
||||
.count())
|
||||
}
|
||||
|
||||
fn command(&self, source: &str, name: &str) -> TestResult<Vec<u8>> {
|
||||
let destination = self.root.0.join(name);
|
||||
std::fs::copy(source, &destination)?;
|
||||
std::fs::set_permissions(&destination, std::fs::Permissions::from_mode(0o700))?;
|
||||
Ok(destination.as_os_str().as_encoded_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingOwnership {
|
||||
staged: Option<StagedProcessOwnership>,
|
||||
last: Option<StagedProcessOwnership>,
|
||||
reject_stage: bool,
|
||||
}
|
||||
|
||||
impl ExecutionOwnershipRecorder for RecordingOwnership {
|
||||
fn stage(&mut self, ownership: StagedProcessOwnership) -> std::io::Result<()> {
|
||||
self.last = Some(ownership);
|
||||
if self.reject_stage {
|
||||
return Err(std::io::Error::other("injected ownership refusal"));
|
||||
}
|
||||
self.staged = Some(ownership);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn acknowledge(
|
||||
&mut self,
|
||||
ownership: StagedProcessOwnership,
|
||||
) -> std::io::Result<ProcessOwnershipCommitAck> {
|
||||
if self.staged.take() == Some(ownership) {
|
||||
Ok(ProcessOwnershipCommitAck::v1(ownership))
|
||||
} else {
|
||||
Err(std::io::Error::other("staged ownership mismatch"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn developer_decision(
|
||||
fixture: &Fixture,
|
||||
request: &ClientRequest<'_>,
|
||||
decision: DeveloperDecisionKind,
|
||||
argv: Vec<Vec<u8>>,
|
||||
) -> TestResult<Vec<u8>> {
|
||||
Ok(encode_signed(
|
||||
&DeveloperDecision {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: fixture.session.session_binding().clone(),
|
||||
request_id: request.request_id(),
|
||||
request_sha256: request.request_digest(),
|
||||
decision,
|
||||
approved_command_argv: argv,
|
||||
},
|
||||
&fixture.anchor_key,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn executable(argv: &[Vec<u8>]) -> TestResult<std::os::fd::OwnedFd> {
|
||||
Ok(DirectDeveloperExecutableResolver::open()?.resolve(argv)?)
|
||||
}
|
||||
|
||||
fn drive_terminal(
|
||||
mailboxes: &Mailboxes,
|
||||
coordinator: &mut EscalationCoordinator,
|
||||
request: &mut ClientRequest<'_>,
|
||||
) -> TestResult<(EscalationOutcome, Vec<u8>)> {
|
||||
let mut output = Vec::new();
|
||||
for _ in 0..5_000 {
|
||||
let _ = coordinator.progress(request.request_id(), Instant::now())?;
|
||||
match request.poll()? {
|
||||
ClientEvent::Output {
|
||||
sequence, bytes, ..
|
||||
} => {
|
||||
output.extend_from_slice(&bytes);
|
||||
let ack = mailboxes.consume_ack(request.request_id(), sequence)?;
|
||||
coordinator.acknowledge(request.request_id(), &ack, Instant::now())?;
|
||||
}
|
||||
ClientEvent::Terminal(result) => return Ok((result.outcome, output)),
|
||||
ClientEvent::Pending => std::thread::sleep(Duration::from_millis(1)),
|
||||
}
|
||||
}
|
||||
Err("terminal result deadline exhausted".into())
|
||||
}
|
||||
|
||||
fn completed_auto_and_developer_requests_preserve_attribution() -> TestResult {
|
||||
for (seed, auto) in [(10, true), (30, false)] {
|
||||
let fixture = make_fixture(seed)?;
|
||||
let mailboxes = Mailboxes::create(if auto { "auto" } else { "developer" })?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let argv = vec![
|
||||
mailboxes.command("/usr/bin/printf", "printf")?,
|
||||
b"hello".to_vec(),
|
||||
];
|
||||
let mut request = client.request(argv.clone(), "synthetic output".into())?;
|
||||
let signed_request = mailboxes.consume_request(request.request_id())?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
let mut ownership = RecordingOwnership::default();
|
||||
if auto {
|
||||
assert_eq!(
|
||||
coordinator.receive_auto_approved(
|
||||
fixture.session.clone(),
|
||||
&signed_request,
|
||||
executable(&argv)?,
|
||||
argv,
|
||||
&mut ownership,
|
||||
Instant::now(),
|
||||
)?,
|
||||
EscalationPhase::Executing
|
||||
);
|
||||
assert_eq!(
|
||||
coordinator.approval_source(request.request_id()),
|
||||
Some(ApprovalSource::AutoRule)
|
||||
);
|
||||
} else {
|
||||
coordinator.receive_for_developer_approval(
|
||||
fixture.session.clone(),
|
||||
&signed_request,
|
||||
Instant::now(),
|
||||
)?;
|
||||
let decision =
|
||||
developer_decision(&fixture, &request, DeveloperDecisionKind::Approve, argv)?;
|
||||
let mut resolver = DirectDeveloperExecutableResolver::open()?;
|
||||
coordinator.apply_developer_decision(
|
||||
request.request_id(),
|
||||
&decision,
|
||||
&mut resolver,
|
||||
&mut ownership,
|
||||
)?;
|
||||
assert_eq!(
|
||||
coordinator.approval_source(request.request_id()),
|
||||
Some(ApprovalSource::Developer)
|
||||
);
|
||||
}
|
||||
let (outcome, output) = drive_terminal(&mailboxes, &mut coordinator, &mut request)?;
|
||||
assert_eq!(outcome, EscalationOutcome::Completed);
|
||||
assert_eq!(output, b"hello");
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.approval_attribution(request.request_id())
|
||||
.unwrap()
|
||||
.source,
|
||||
if auto {
|
||||
ApprovalSource::AutoRule
|
||||
} else {
|
||||
ApprovalSource::Developer
|
||||
}
|
||||
);
|
||||
assert!(recover_process(ownership.last.unwrap().identity()).is_err());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn denial_failure_and_request_timeout_publish_terminal_outcomes() -> TestResult {
|
||||
let fixture = make_fixture(50)?;
|
||||
let mailboxes = Mailboxes::create("terminal-outcomes")?;
|
||||
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let argv = vec![
|
||||
mailboxes.command("/usr/bin/printf", "printf")?,
|
||||
b"denied".to_vec(),
|
||||
];
|
||||
let mut denied = client.request(argv, "deny".into())?;
|
||||
let signed = mailboxes.consume_request(denied.request_id())?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
coordinator.receive_for_developer_approval(fixture.session.clone(), &signed, Instant::now())?;
|
||||
let decision = developer_decision(&fixture, &denied, DeveloperDecisionKind::Deny, Vec::new())?;
|
||||
let mut resolver = DirectDeveloperExecutableResolver::open()?;
|
||||
coordinator.apply_developer_decision(
|
||||
denied.request_id(),
|
||||
&decision,
|
||||
&mut resolver,
|
||||
&mut RecordingOwnership::default(),
|
||||
)?;
|
||||
assert!(
|
||||
matches!(denied.poll()?, ClientEvent::Terminal(result) if result.outcome == EscalationOutcome::Denied)
|
||||
);
|
||||
|
||||
let fixture = make_fixture(60)?;
|
||||
let mailboxes = Mailboxes::create("failed")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let argv = vec![
|
||||
mailboxes.command("/usr/bin/printf", "printf")?,
|
||||
b"failed".to_vec(),
|
||||
];
|
||||
let mut failed = client.request(argv.clone(), "failure".into())?;
|
||||
let signed = mailboxes.consume_request(failed.request_id())?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
let mut ownership = RecordingOwnership {
|
||||
reject_stage: true,
|
||||
..RecordingOwnership::default()
|
||||
};
|
||||
assert_eq!(
|
||||
coordinator.receive_auto_approved(
|
||||
fixture.session.clone(),
|
||||
&signed,
|
||||
executable(&argv)?,
|
||||
argv,
|
||||
&mut ownership,
|
||||
Instant::now(),
|
||||
)?,
|
||||
EscalationPhase::Delivered(EscalationOutcome::Failed)
|
||||
);
|
||||
assert!(
|
||||
matches!(failed.poll()?, ClientEvent::Terminal(result) if result.outcome == EscalationOutcome::Failed)
|
||||
);
|
||||
assert!(recover_process(ownership.last.unwrap().identity()).is_err());
|
||||
|
||||
let fixture = make_fixture(70)?;
|
||||
let mailboxes = Mailboxes::create("request-timeout")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let mut timed_out = client.request(
|
||||
vec![mailboxes.command("/usr/bin/printf", "printf")?],
|
||||
"timeout".into(),
|
||||
)?;
|
||||
let signed = mailboxes.consume_request(timed_out.request_id())?;
|
||||
let start = Instant::now();
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_millis(10), Duration::from_secs(2))?;
|
||||
coordinator.receive_for_developer_approval(fixture.session.clone(), &signed, start)?;
|
||||
assert_eq!(
|
||||
coordinator.expire(start + Duration::from_millis(20))?,
|
||||
vec![timed_out.request_id()]
|
||||
);
|
||||
assert!(
|
||||
matches!(timed_out.poll()?, ClientEvent::Terminal(result) if result.outcome == EscalationOutcome::TimedOut)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancellation_session_loss_and_ack_timeout_reap_exact_children() -> TestResult {
|
||||
let fixture = make_fixture(80)?;
|
||||
let mailboxes = Mailboxes::create("cancel")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let argv = vec![
|
||||
mailboxes.command("/usr/bin/sleep", "sleep")?,
|
||||
b"30".to_vec(),
|
||||
];
|
||||
let mut request = client.request(argv.clone(), "cancel".into())?;
|
||||
let signed = mailboxes.consume_request(request.request_id())?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
let mut ownership = RecordingOwnership::default();
|
||||
coordinator.receive_auto_approved(
|
||||
fixture.session.clone(),
|
||||
&signed,
|
||||
executable(&argv)?,
|
||||
argv,
|
||||
&mut ownership,
|
||||
Instant::now(),
|
||||
)?;
|
||||
request.cancel()?;
|
||||
let cancellation_name = lazaret_core::protocol::cancellation_file_name(request.request_id());
|
||||
let cancellation = RegularFileMailbox::open(&mailboxes.inbox_path)?
|
||||
.consume(&cancellation_name, MAX_SIGNED_RECORD_BYTES)?;
|
||||
coordinator.cancel(request.request_id(), &cancellation)?;
|
||||
assert!(
|
||||
matches!(request.poll()?, ClientEvent::Terminal(result) if result.outcome == EscalationOutcome::Cancelled)
|
||||
);
|
||||
assert!(recover_process(ownership.last.unwrap().identity()).is_err());
|
||||
|
||||
let fixture = make_fixture(90)?;
|
||||
let mailboxes = Mailboxes::create("session-loss")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let argv = vec![
|
||||
mailboxes.command("/usr/bin/sleep", "sleep")?,
|
||||
b"30".to_vec(),
|
||||
];
|
||||
let request = client.request(argv.clone(), "session loss".into())?;
|
||||
let signed = mailboxes.consume_request(request.request_id())?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
let mut ownership = RecordingOwnership::default();
|
||||
coordinator.receive_auto_approved(
|
||||
fixture.session.clone(),
|
||||
&signed,
|
||||
executable(&argv)?,
|
||||
argv,
|
||||
&mut ownership,
|
||||
Instant::now(),
|
||||
)?;
|
||||
coordinator.session_lost(fixture.session.session_binding().session_id)?;
|
||||
assert!(recover_process(ownership.last.unwrap().identity()).is_err());
|
||||
assert!(
|
||||
RegularFileMailbox::open(&mailboxes.outbox_path)?
|
||||
.names()?
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let fixture = make_fixture(100)?;
|
||||
let mailboxes = Mailboxes::create("ack-timeout")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let argv = vec![
|
||||
mailboxes.command("/usr/bin/head", "head")?,
|
||||
b"-c".to_vec(),
|
||||
b"1000000".to_vec(),
|
||||
b"/dev/zero".to_vec(),
|
||||
];
|
||||
let mut request = client.request(argv.clone(), "backpressure timeout".into())?;
|
||||
let signed = mailboxes.consume_request(request.request_id())?;
|
||||
let start = Instant::now();
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_millis(10))?;
|
||||
let mut ownership = RecordingOwnership::default();
|
||||
coordinator.receive_auto_approved(
|
||||
fixture.session.clone(),
|
||||
&signed,
|
||||
executable(&argv)?,
|
||||
argv,
|
||||
&mut ownership,
|
||||
start,
|
||||
)?;
|
||||
for _ in 0..500 {
|
||||
coordinator.progress(request.request_id(), start)?;
|
||||
if mailboxes.result_chunk_count()? > 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(mailboxes.result_chunk_count()? > 0);
|
||||
coordinator.expire(start + Duration::from_millis(20))?;
|
||||
assert!(
|
||||
matches!(request.poll()?, ClientEvent::Terminal(result) if result.outcome == EscalationOutcome::TimedOut)
|
||||
);
|
||||
assert!(recover_process(ownership.last.unwrap().identity()).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_window_never_exceeds_eight_unacknowledged_chunks() -> TestResult {
|
||||
let fixture = make_fixture(110)?;
|
||||
let mailboxes = Mailboxes::create("window")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let argv = vec![
|
||||
mailboxes.command("/usr/bin/head", "head")?,
|
||||
b"-c".to_vec(),
|
||||
b"1000000".to_vec(),
|
||||
b"/dev/zero".to_vec(),
|
||||
];
|
||||
let mut request = client.request(argv.clone(), "bounded output".into())?;
|
||||
let signed = mailboxes.consume_request(request.request_id())?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(10), Duration::from_secs(5))?;
|
||||
coordinator.receive_auto_approved(
|
||||
fixture.session.clone(),
|
||||
&signed,
|
||||
executable(&argv)?,
|
||||
argv,
|
||||
&mut RecordingOwnership::default(),
|
||||
Instant::now(),
|
||||
)?;
|
||||
let mut reached_bound = false;
|
||||
for _ in 0..1_000 {
|
||||
coordinator.progress(request.request_id(), Instant::now())?;
|
||||
let count = mailboxes.result_chunk_count()?;
|
||||
assert!(count <= 8);
|
||||
if count == 8 {
|
||||
reached_bound = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(reached_bound);
|
||||
let (outcome, output) = drive_terminal(&mailboxes, &mut coordinator, &mut request)?;
|
||||
assert_eq!(outcome, EscalationOutcome::Completed);
|
||||
assert_eq!(output.len(), 1_000_000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replay_and_session_tuple_mismatch_refuse() -> TestResult {
|
||||
let fixture = make_fixture(120)?;
|
||||
let mailboxes = Mailboxes::create("replay")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let request = client.request(
|
||||
vec![mailboxes.command("/usr/bin/printf", "printf")?],
|
||||
"replay".into(),
|
||||
)?;
|
||||
let signed = mailboxes.consume_request(request.request_id())?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
coordinator.receive_for_developer_approval(fixture.session.clone(), &signed, Instant::now())?;
|
||||
coordinator.session_lost(fixture.session.session_binding().session_id)?;
|
||||
assert!(matches!(
|
||||
coordinator.receive_for_developer_approval(
|
||||
fixture.session.clone(),
|
||||
&signed,
|
||||
Instant::now()
|
||||
),
|
||||
Err(EscalationError::Protocol(
|
||||
ProtocolValidationError::RequestReplay
|
||||
))
|
||||
));
|
||||
|
||||
let other = make_fixture(130)?;
|
||||
let other_mailboxes = Mailboxes::create("tuple")?;
|
||||
let mut other_coordinator =
|
||||
other_mailboxes.coordinator(&other, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
assert!(matches!(
|
||||
other_coordinator.receive_for_developer_approval(other.session, &signed, Instant::now()),
|
||||
Err(EscalationError::Protocol(
|
||||
ProtocolValidationError::SessionBindingMismatch
|
||||
| ProtocolValidationError::InvalidPublicKey
|
||||
| ProtocolValidationError::Codec(_)
|
||||
))
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn signed_cancellation_decision_and_result_tuple_mismatches_refuse() -> TestResult {
|
||||
let fixture = make_fixture(150)?;
|
||||
let other = make_fixture(160)?;
|
||||
let mailboxes = Mailboxes::create("signed-tuples")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let mut request = client.request(
|
||||
vec![mailboxes.command("/usr/bin/printf", "printf")?],
|
||||
"tuple mismatch".into(),
|
||||
)?;
|
||||
let signed = mailboxes.consume_request(request.request_id())?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
coordinator.receive_for_developer_approval(fixture.session.clone(), &signed, Instant::now())?;
|
||||
|
||||
let cancellation = encode_signed(
|
||||
&MailboxCancellation {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: other.session.session_binding().clone(),
|
||||
request_id: request.request_id(),
|
||||
session_capability: other.session.session_capability().clone(),
|
||||
sandbox_credential_id: other.session.sandbox_credential_id(),
|
||||
},
|
||||
&other.sandbox_key,
|
||||
)?;
|
||||
assert!(matches!(
|
||||
coordinator.cancel(request.request_id(), &cancellation),
|
||||
Err(EscalationError::Protocol(_))
|
||||
));
|
||||
|
||||
let wrong_decision = encode_signed(
|
||||
&DeveloperDecision {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: other.session.session_binding().clone(),
|
||||
request_id: request.request_id(),
|
||||
request_sha256: request.request_digest(),
|
||||
decision: DeveloperDecisionKind::Deny,
|
||||
approved_command_argv: Vec::new(),
|
||||
},
|
||||
&fixture.anchor_key,
|
||||
)?;
|
||||
assert!(matches!(
|
||||
coordinator.apply_developer_decision(
|
||||
request.request_id(),
|
||||
&wrong_decision,
|
||||
&mut DirectDeveloperExecutableResolver::open()?,
|
||||
&mut RecordingOwnership::default(),
|
||||
),
|
||||
Err(EscalationError::Protocol(
|
||||
ProtocolValidationError::SessionBindingMismatch
|
||||
))
|
||||
));
|
||||
|
||||
let empty = StreamDigest::new(Sha256::digest([]).into());
|
||||
let wrong_result = encode_signed(
|
||||
&MailboxResult {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: other.session.session_binding().clone(),
|
||||
request_id: request.request_id(),
|
||||
request_sha256: request.request_digest(),
|
||||
coordinator_credential_id: fixture.session.coordinator_credential_id(),
|
||||
outcome: EscalationOutcome::Denied,
|
||||
exit_code: None,
|
||||
chunk_count: 0,
|
||||
stdout_sha256: empty,
|
||||
stderr_sha256: empty,
|
||||
},
|
||||
&fixture.coordinator_key,
|
||||
)?;
|
||||
RegularFileMailbox::open(&mailboxes.outbox_path)?.publish(
|
||||
&lazaret_core::protocol::result_file_name(request.request_id()),
|
||||
&wrong_result,
|
||||
)?;
|
||||
assert!(matches!(
|
||||
request.poll(),
|
||||
Err(lazaret_escalation_client::ClientError::Protocol(
|
||||
ProtocolValidationError::SessionBindingMismatch
|
||||
))
|
||||
));
|
||||
coordinator.session_lost(fixture.session.session_binding().session_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exact_descriptor_launcher_survives_path_replacement() -> TestResult {
|
||||
let fixture = make_fixture(140)?;
|
||||
let mailboxes = Mailboxes::create("descriptor")?;
|
||||
let client = mailboxes.client(&fixture)?;
|
||||
let root = TestDirectory::create("descriptor-target")?;
|
||||
let executable_path = root.0.join("command");
|
||||
std::fs::copy("/usr/bin/printf", &executable_path)?;
|
||||
std::fs::set_permissions(&executable_path, std::fs::Permissions::from_mode(0o700))?;
|
||||
let argv = vec![
|
||||
executable_path.as_os_str().as_encoded_bytes().to_vec(),
|
||||
b"descriptor".to_vec(),
|
||||
];
|
||||
let mut request = client.request(argv.clone(), "descriptor authority".into())?;
|
||||
let signed = mailboxes.consume_request(request.request_id())?;
|
||||
let executable = OpenOptions::new().read(true).open(&executable_path)?;
|
||||
let executable = executable.into();
|
||||
std::fs::rename(&executable_path, root.0.join("approved"))?;
|
||||
std::fs::write(&executable_path, b"not executable")?;
|
||||
let mut coordinator =
|
||||
mailboxes.coordinator(&fixture, Duration::from_secs(5), Duration::from_secs(2))?;
|
||||
coordinator.receive_auto_approved(
|
||||
fixture.session.clone(),
|
||||
&signed,
|
||||
executable,
|
||||
argv,
|
||||
&mut RecordingOwnership::default(),
|
||||
Instant::now(),
|
||||
)?;
|
||||
let (outcome, output) = drive_terminal(&mailboxes, &mut coordinator, &mut request)?;
|
||||
assert_eq!(outcome, EscalationOutcome::Completed);
|
||||
assert_eq!(output, b"descriptor");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generation_mailbox_is_confined_and_generation_scoped() -> TestResult {
|
||||
let fixture = make_fixture(170)?;
|
||||
let root = TestDirectory::create("generation-mailbox")?;
|
||||
let root_file = File::open(&root.0)?;
|
||||
let mailbox = GenerationMailbox::create(
|
||||
root_file.as_fd(),
|
||||
&fixture.generation,
|
||||
rustix::process::getuid().as_raw(),
|
||||
)?;
|
||||
assert!(mailbox.inbox().names()?.is_empty());
|
||||
assert!(mailbox.outbox().names()?.is_empty());
|
||||
let generation_name =
|
||||
lazaret_core::protocol::mailbox_generation_name(fixture.generation.generation_id.get());
|
||||
let generation_path = root.0.join("mailbox").join(generation_name);
|
||||
let identity = std::fs::symlink_metadata(generation_path.join("generation-identity.postcard"))?;
|
||||
assert!(identity.file_type().is_file());
|
||||
assert_eq!(identity.nlink(), 1);
|
||||
assert_eq!(identity.mode() & 0o7777, 0o600);
|
||||
for directory in [
|
||||
generation_path.join("inbox"),
|
||||
generation_path.join("outbox"),
|
||||
] {
|
||||
let metadata = std::fs::symlink_metadata(directory)?;
|
||||
assert!(metadata.file_type().is_dir());
|
||||
assert_eq!(metadata.mode() & 0o7777, 0o700);
|
||||
}
|
||||
assert!(
|
||||
GenerationMailbox::create(
|
||||
root_file.as_fd(),
|
||||
&fixture.generation,
|
||||
rustix::process::getuid().as_raw(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let hostile = TestDirectory::create("generation-mailbox-link")?;
|
||||
symlink(&root.0, hostile.0.join("mailbox"))?;
|
||||
let hostile_file = File::open(&hostile.0)?;
|
||||
assert!(
|
||||
GenerationMailbox::create(
|
||||
hostile_file.as_fd(),
|
||||
&fixture.generation,
|
||||
rustix::process::getuid().as_raw(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run() -> TestResult {
|
||||
completed_auto_and_developer_requests_preserve_attribution()?;
|
||||
denial_failure_and_request_timeout_publish_terminal_outcomes()?;
|
||||
cancellation_session_loss_and_ack_timeout_reap_exact_children()?;
|
||||
output_window_never_exceeds_eight_unacknowledged_chunks()?;
|
||||
replay_and_session_tuple_mismatch_refuse()?;
|
||||
signed_cancellation_decision_and_result_tuple_mismatches_refuse()?;
|
||||
exact_descriptor_launcher_survives_path_replacement()?;
|
||||
generation_mailbox_is_confined_and_generation_scoped()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if let Some(result) = dispatch_private_process_role(&arguments) {
|
||||
if let Err(error) = result {
|
||||
eprintln!("private process role failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if arguments.iter().any(|argument| argument == "--list") {
|
||||
println!("escalation: test");
|
||||
return;
|
||||
}
|
||||
if let Err(error) = run() {
|
||||
eprintln!("escalation verifier failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue