Add confined host escalation transport
This commit is contained in:
parent
903ff837d6
commit
407b75b1ff
16 changed files with 2888 additions and 21 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -1135,7 +1135,12 @@ dependencies = [
|
||||||
name = "lazaret-escalation-client"
|
name = "lazaret-escalation-client"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ed25519-dalek",
|
||||||
|
"getrandom 0.4.3",
|
||||||
"lazaret-core",
|
"lazaret-core",
|
||||||
|
"lazaret-linux",
|
||||||
|
"rustix",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1145,6 +1150,7 @@ dependencies = [
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"lazaret-core",
|
"lazaret-core",
|
||||||
|
"lazaret-escalation-client",
|
||||||
"lazaret-linux",
|
"lazaret-linux",
|
||||||
"lazaret-runtime-supervisor",
|
"lazaret-runtime-supervisor",
|
||||||
"lazaret-state-anchor",
|
"lazaret-state-anchor",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
//! Bounded cryptographic protocol records and generation-scoped validation.
|
//! Bounded cryptographic protocol records and generation-scoped validation.
|
||||||
|
|
||||||
|
mod names;
|
||||||
mod session;
|
mod session;
|
||||||
mod signing;
|
mod signing;
|
||||||
mod window;
|
mod window;
|
||||||
|
|
||||||
|
pub use names::*;
|
||||||
pub use session::*;
|
pub use session::*;
|
||||||
pub use signing::*;
|
pub use signing::*;
|
||||||
pub use window::*;
|
pub use window::*;
|
||||||
|
|
|
||||||
67
crates/lazaret-core/src/protocol/names.rs
Normal file
67
crates/lazaret-core/src/protocol/names.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
|
||||||
|
use crate::identity::{RequestId, SessionId};
|
||||||
|
|
||||||
|
fn identifier_hex(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
bytes.iter().fold(String::new(), |mut encoded, byte| {
|
||||||
|
write!(encoded, "{byte:02x}").expect("writing to a String cannot fail");
|
||||||
|
encoded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn mailbox_generation_name(generation: u64) -> OsString {
|
||||||
|
OsString::from(format!("generation-{generation:020}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn mailbox_session_name(session_id: SessionId) -> OsString {
|
||||||
|
OsString::from(format!("session-{}", identifier_hex(session_id.as_bytes())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_file_name(request_id: RequestId) -> OsString {
|
||||||
|
OsString::from(format!(
|
||||||
|
"request-{}.ready",
|
||||||
|
identifier_hex(request_id.as_bytes())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn cancellation_file_name(request_id: RequestId) -> OsString {
|
||||||
|
OsString::from(format!(
|
||||||
|
"cancel-{}.ready",
|
||||||
|
identifier_hex(request_id.as_bytes())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn acknowledgment_file_name(request_id: RequestId, sequence: u64) -> OsString {
|
||||||
|
OsString::from(format!(
|
||||||
|
"ack-{}-{sequence:020}.ready",
|
||||||
|
identifier_hex(request_id.as_bytes())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn result_file_name(request_id: RequestId) -> OsString {
|
||||||
|
OsString::from(format!(
|
||||||
|
"result-{}.ready",
|
||||||
|
identifier_hex(request_id.as_bytes())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn result_chunk_file_name(request_id: RequestId, sequence: u64) -> OsString {
|
||||||
|
OsString::from(format!(
|
||||||
|
"result-chunk-{}-{sequence:020}.ready",
|
||||||
|
identifier_hex(request_id.as_bytes())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_ready_record(name: &OsStr) -> bool {
|
||||||
|
name.as_encoded_bytes().ends_with(b".ready")
|
||||||
|
}
|
||||||
|
|
@ -395,6 +395,26 @@ pub struct VerifiedSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VerifiedSession {
|
impl VerifiedSession {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn session_binding(&self) -> &SessionBinding {
|
||||||
|
&self.session_binding
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn session_capability(&self) -> &SessionRequestCapability {
|
||||||
|
&self.capability
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sandbox_credential_id(&self) -> ProtocolCredentialId {
|
||||||
|
self.sandbox_credential_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn coordinator_credential_id(&self) -> ProtocolCredentialId {
|
||||||
|
self.coordinator_credential_id
|
||||||
|
}
|
||||||
|
|
||||||
/// Constructs an authority only after validating its manifest, credentials, and capability.
|
/// Constructs an authority only after validating its manifest, credentials, and capability.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,11 @@ impl ResultSenderWindow {
|
||||||
pub fn outstanding(&self) -> usize {
|
pub fn outstanding(&self) -> usize {
|
||||||
usize::try_from(self.sent_count - self.acknowledged_count).unwrap_or(usize::MAX)
|
usize::try_from(self.sent_count - self.acknowledged_count).unwrap_or(usize::MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sent_count(&self) -> u64 {
|
||||||
|
self.sent_count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@ name = "lazaret-escalation-client"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ed25519-dalek.workspace = true
|
||||||
|
getrandom.workspace = true
|
||||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||||
|
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||||
|
rustix.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
253
crates/lazaret-escalation-client/src/lib.rs
Normal file
253
crates/lazaret-escalation-client/src/lib.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
use std::fmt;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use lazaret_core::identity::{RequestDigest, RequestId};
|
||||||
|
use lazaret_core::protocol::{
|
||||||
|
MAX_SIGNED_RECORD_BYTES, ProtocolCodecError, ProtocolValidationError, ResultCollector,
|
||||||
|
ResultWindowError, VerifiedSession, acknowledgment_file_name, cancellation_file_name,
|
||||||
|
encode_signed, request_file_name, result_chunk_file_name, result_file_name,
|
||||||
|
};
|
||||||
|
use lazaret_core::records::{
|
||||||
|
MailboxCancellation, MailboxRequest, MailboxRequestError, MailboxResult, MailboxResultAck,
|
||||||
|
ProtocolVersion, ResultStream,
|
||||||
|
};
|
||||||
|
use lazaret_linux::filesystem::RegularFileMailbox;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ClientError {
|
||||||
|
Io(io::Error),
|
||||||
|
Request(MailboxRequestError),
|
||||||
|
Protocol(ProtocolValidationError),
|
||||||
|
ProtocolCodec(ProtocolCodecError),
|
||||||
|
Window(ResultWindowError),
|
||||||
|
Random(getrandom::Error),
|
||||||
|
TerminalAlreadyReceived,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ClientError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => error.fmt(formatter),
|
||||||
|
Self::Request(error) => error.fmt(formatter),
|
||||||
|
Self::Protocol(error) => error.fmt(formatter),
|
||||||
|
Self::ProtocolCodec(error) => error.fmt(formatter),
|
||||||
|
Self::Window(error) => error.fmt(formatter),
|
||||||
|
Self::Random(error) => error.fmt(formatter),
|
||||||
|
Self::TerminalAlreadyReceived => {
|
||||||
|
formatter.write_str("escalation request already received a terminal result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ClientError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => Some(error),
|
||||||
|
Self::Request(error) => Some(error),
|
||||||
|
Self::Protocol(error) => Some(error),
|
||||||
|
Self::ProtocolCodec(error) => Some(error),
|
||||||
|
Self::Window(error) => Some(error),
|
||||||
|
Self::Random(error) => Some(error),
|
||||||
|
Self::TerminalAlreadyReceived => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! from_error {
|
||||||
|
($source:ty, $variant:ident) => {
|
||||||
|
impl From<$source> for ClientError {
|
||||||
|
fn from(error: $source) -> Self {
|
||||||
|
Self::$variant(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
from_error!(io::Error, Io);
|
||||||
|
from_error!(MailboxRequestError, Request);
|
||||||
|
from_error!(ProtocolValidationError, Protocol);
|
||||||
|
from_error!(ProtocolCodecError, ProtocolCodec);
|
||||||
|
from_error!(ResultWindowError, Window);
|
||||||
|
from_error!(getrandom::Error, Random);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ClientEvent {
|
||||||
|
Output {
|
||||||
|
sequence: u64,
|
||||||
|
stream: ResultStream,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
Terminal(Box<MailboxResult>),
|
||||||
|
Pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EscalationClient {
|
||||||
|
inbox: RegularFileMailbox,
|
||||||
|
outbox: RegularFileMailbox,
|
||||||
|
session: VerifiedSession,
|
||||||
|
sandbox_key: SigningKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EscalationClient {
|
||||||
|
/// Constructs a static client over already-mounted mailbox directories and live authority.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(
|
||||||
|
inbox: RegularFileMailbox,
|
||||||
|
outbox: RegularFileMailbox,
|
||||||
|
session: VerifiedSession,
|
||||||
|
sandbox_key: SigningKey,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inbox,
|
||||||
|
outbox,
|
||||||
|
session,
|
||||||
|
sandbox_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs and atomically publishes one structured argv request.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses invalid argv, random-ID failure, tuple mismatch, or publication failure.
|
||||||
|
pub fn request(
|
||||||
|
&self,
|
||||||
|
command_argv: Vec<Vec<u8>>,
|
||||||
|
justification: String,
|
||||||
|
) -> Result<ClientRequest<'_>, ClientError> {
|
||||||
|
let request_id = random_request_id()?;
|
||||||
|
let request = MailboxRequest::new(
|
||||||
|
self.session.session_binding().clone(),
|
||||||
|
request_id,
|
||||||
|
self.session.session_capability().clone(),
|
||||||
|
self.session.sandbox_credential_id(),
|
||||||
|
command_argv,
|
||||||
|
justification,
|
||||||
|
)?;
|
||||||
|
let signed = encode_signed(&request, &self.sandbox_key)?;
|
||||||
|
let (_, digest) = self.session.verify_request(&signed)?;
|
||||||
|
self.inbox
|
||||||
|
.publish(&request_file_name(request_id), &signed)?;
|
||||||
|
Ok(ClientRequest {
|
||||||
|
client: self,
|
||||||
|
request_id,
|
||||||
|
digest,
|
||||||
|
collector: ResultCollector::default(),
|
||||||
|
terminal: false,
|
||||||
|
cancellation_published: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ClientRequest<'a> {
|
||||||
|
client: &'a EscalationClient,
|
||||||
|
request_id: RequestId,
|
||||||
|
digest: RequestDigest,
|
||||||
|
collector: ResultCollector,
|
||||||
|
terminal: bool,
|
||||||
|
cancellation_published: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientRequest<'_> {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn request_id(&self) -> RequestId {
|
||||||
|
self.request_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn request_digest(&self) -> RequestDigest {
|
||||||
|
self.digest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes one signed cancellation for this exact request.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses a terminal request, duplicate local cancellation, or publication failure.
|
||||||
|
pub fn cancel(&mut self) -> Result<(), ClientError> {
|
||||||
|
if self.terminal || self.cancellation_published {
|
||||||
|
return Err(ClientError::TerminalAlreadyReceived);
|
||||||
|
}
|
||||||
|
let cancellation = MailboxCancellation {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: self.client.session.session_binding().clone(),
|
||||||
|
request_id: self.request_id,
|
||||||
|
session_capability: self.client.session.session_capability().clone(),
|
||||||
|
sandbox_credential_id: self.client.session.sandbox_credential_id(),
|
||||||
|
};
|
||||||
|
let signed = encode_signed(&cancellation, &self.client.sandbox_key)?;
|
||||||
|
self.client
|
||||||
|
.inbox
|
||||||
|
.publish(&cancellation_file_name(self.request_id), &signed)?;
|
||||||
|
self.cancellation_published = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads at most one verified output chunk or terminal record without unbounded buffering.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses malformed, oversized, stale, reordered, duplicated, or mismatched result records.
|
||||||
|
pub fn poll(&mut self) -> Result<ClientEvent, ClientError> {
|
||||||
|
if self.terminal {
|
||||||
|
return Err(ClientError::TerminalAlreadyReceived);
|
||||||
|
}
|
||||||
|
let sequence = self
|
||||||
|
.collector
|
||||||
|
.highest_contiguous_sequence()
|
||||||
|
.map_or(0, |value| value.saturating_add(1));
|
||||||
|
let chunk_name = result_chunk_file_name(self.request_id, sequence);
|
||||||
|
if self.client.outbox.names()?.contains(&chunk_name) {
|
||||||
|
let (encoded, _) = self
|
||||||
|
.client
|
||||||
|
.outbox
|
||||||
|
.read(&chunk_name, MAX_SIGNED_RECORD_BYTES)?;
|
||||||
|
let chunk =
|
||||||
|
self.client
|
||||||
|
.session
|
||||||
|
.verify_result_chunk(&encoded, self.request_id, self.digest)?;
|
||||||
|
let mut collector = self.collector.clone();
|
||||||
|
collector.push(&chunk)?;
|
||||||
|
let acknowledgment = MailboxResultAck {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: self.client.session.session_binding().clone(),
|
||||||
|
request_id: self.request_id,
|
||||||
|
request_sha256: self.digest,
|
||||||
|
session_capability: self.client.session.session_capability().clone(),
|
||||||
|
sandbox_credential_id: self.client.session.sandbox_credential_id(),
|
||||||
|
highest_contiguous_sequence: chunk.sequence,
|
||||||
|
};
|
||||||
|
let signed = encode_signed(&acknowledgment, &self.client.sandbox_key)?;
|
||||||
|
self.client.inbox.publish(
|
||||||
|
&acknowledgment_file_name(self.request_id, chunk.sequence),
|
||||||
|
&signed,
|
||||||
|
)?;
|
||||||
|
self.collector = collector;
|
||||||
|
return Ok(ClientEvent::Output {
|
||||||
|
sequence: chunk.sequence,
|
||||||
|
stream: chunk.stream,
|
||||||
|
bytes: chunk.bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let result_name = result_file_name(self.request_id);
|
||||||
|
if self.client.outbox.names()?.contains(&result_name) {
|
||||||
|
let encoded = self
|
||||||
|
.client
|
||||||
|
.outbox
|
||||||
|
.consume(&result_name, MAX_SIGNED_RECORD_BYTES)?;
|
||||||
|
let result =
|
||||||
|
self.client
|
||||||
|
.session
|
||||||
|
.verify_result(&encoded, self.request_id, self.digest)?;
|
||||||
|
self.collector.finish(&result)?;
|
||||||
|
self.terminal = true;
|
||||||
|
return Ok(ClientEvent::Terminal(Box::new(result)));
|
||||||
|
}
|
||||||
|
Ok(ClientEvent::Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_request_id() -> Result<RequestId, ClientError> {
|
||||||
|
let mut bytes = [0_u8; 32];
|
||||||
|
getrandom::fill(&mut bytes)?;
|
||||||
|
Ok(RequestId::new(bytes))
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,232 @@
|
||||||
fn main() {
|
use std::ffi::OsString;
|
||||||
let _ = lazaret_core::PROTOCOL_NAME;
|
use std::io::{self, Read, Write};
|
||||||
|
use std::os::unix::ffi::OsStringExt as _;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use lazaret_core::codec::decode;
|
||||||
|
use lazaret_core::protocol::VerifiedSession;
|
||||||
|
use lazaret_core::records::{
|
||||||
|
EscalationOutcome, ProtocolPublicationIdentity, SandboxCredentialBundle, SessionBinding,
|
||||||
|
SessionRegistrationResponse,
|
||||||
|
};
|
||||||
|
use lazaret_escalation_client::{ClientEvent, EscalationClient};
|
||||||
|
use lazaret_linux::filesystem::RegularFileMailbox;
|
||||||
|
|
||||||
|
struct Arguments {
|
||||||
|
inbox: PathBuf,
|
||||||
|
outbox: PathBuf,
|
||||||
|
publication: PathBuf,
|
||||||
|
sandbox_credentials: PathBuf,
|
||||||
|
registration: PathBuf,
|
||||||
|
session_binding: PathBuf,
|
||||||
|
justification: String,
|
||||||
|
command_argv: Vec<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match run() {
|
||||||
|
Ok(code) => ExitCode::from(code),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("lazaret escalation refused: {error}");
|
||||||
|
ExitCode::from(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<u8, Box<dyn std::error::Error>> {
|
||||||
|
let arguments = parse_arguments(std::env::args_os().skip(1).collect())?;
|
||||||
|
let publication: ProtocolPublicationIdentity = read_record(&arguments.publication)?;
|
||||||
|
let bundle: SandboxCredentialBundle = read_record(&arguments.sandbox_credentials)?;
|
||||||
|
let registration: SessionRegistrationResponse = read_record(&arguments.registration)?;
|
||||||
|
let binding: SessionBinding = read_record(&arguments.session_binding)?;
|
||||||
|
let sandbox_key_bytes: [u8; 32] = bundle.sandbox_private_key.as_slice().try_into()?;
|
||||||
|
let sandbox_key = SigningKey::from_bytes(&sandbox_key_bytes);
|
||||||
|
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(),
|
||||||
|
registration.session_capability,
|
||||||
|
)?;
|
||||||
|
let client = EscalationClient::new(
|
||||||
|
RegularFileMailbox::open(&arguments.inbox)?,
|
||||||
|
RegularFileMailbox::open(&arguments.outbox)?,
|
||||||
|
session,
|
||||||
|
sandbox_key,
|
||||||
|
);
|
||||||
|
let mut request = client.request(arguments.command_argv, arguments.justification)?;
|
||||||
|
loop {
|
||||||
|
match request.poll()? {
|
||||||
|
ClientEvent::Output { stream, bytes, .. } => match stream {
|
||||||
|
lazaret_core::records::ResultStream::Stdout => {
|
||||||
|
io::stdout().write_all(&bytes)?;
|
||||||
|
io::stdout().flush()?;
|
||||||
|
}
|
||||||
|
lazaret_core::records::ResultStream::Stderr => {
|
||||||
|
io::stderr().write_all(&bytes)?;
|
||||||
|
io::stderr().flush()?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ClientEvent::Terminal(result) => {
|
||||||
|
return Ok(match result.outcome {
|
||||||
|
EscalationOutcome::Completed => result
|
||||||
|
.exit_code
|
||||||
|
.and_then(|code| u8::try_from(code).ok())
|
||||||
|
.unwrap_or(1),
|
||||||
|
EscalationOutcome::Denied => 3,
|
||||||
|
EscalationOutcome::TimedOut => 4,
|
||||||
|
EscalationOutcome::Cancelled => 5,
|
||||||
|
EscalationOutcome::Failed => 6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ClientEvent::Pending => std::thread::sleep(Duration::from_millis(10)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_record<T>(path: &std::path::Path) -> Result<T, Box<dyn std::error::Error>>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned + serde::Serialize,
|
||||||
|
{
|
||||||
|
let mut file = std::fs::File::from(rustix::fs::openat(
|
||||||
|
rustix::fs::CWD,
|
||||||
|
path,
|
||||||
|
rustix::fs::OFlags::RDONLY
|
||||||
|
| rustix::fs::OFlags::NONBLOCK
|
||||||
|
| rustix::fs::OFlags::NOFOLLOW
|
||||||
|
| rustix::fs::OFlags::CLOEXEC,
|
||||||
|
rustix::fs::Mode::empty(),
|
||||||
|
)?);
|
||||||
|
let metadata = file.metadata()?;
|
||||||
|
let length = usize::try_from(metadata.len())?;
|
||||||
|
if !metadata.file_type().is_file() || length > lazaret_core::codec::MAX_SCHEMA_RECORD_BYTES {
|
||||||
|
return Err(io::Error::other("client authority record type or size is invalid").into());
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::with_capacity(length.saturating_add(1));
|
||||||
|
Read::by_ref(&mut file)
|
||||||
|
.take((lazaret_core::codec::MAX_SCHEMA_RECORD_BYTES as u64) + 1)
|
||||||
|
.read_to_end(&mut bytes)?;
|
||||||
|
if bytes.len() != length || file.metadata()?.len() != metadata.len() {
|
||||||
|
return Err(io::Error::other("client authority record changed while read").into());
|
||||||
|
}
|
||||||
|
Ok(decode(&bytes)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_arguments(mut arguments: Vec<OsString>) -> Result<Arguments, io::Error> {
|
||||||
|
let separator = arguments
|
||||||
|
.iter()
|
||||||
|
.position(|argument| argument == "--")
|
||||||
|
.ok_or_else(|| io::Error::other("command separator is missing"))?;
|
||||||
|
let command = arguments.split_off(separator + 1);
|
||||||
|
arguments.pop();
|
||||||
|
if command.is_empty() {
|
||||||
|
return Err(io::Error::other("command argv is empty"));
|
||||||
|
}
|
||||||
|
let mut inbox = None;
|
||||||
|
let mut outbox = None;
|
||||||
|
let mut publication = None;
|
||||||
|
let mut sandbox_credentials = None;
|
||||||
|
let mut registration = None;
|
||||||
|
let mut session_binding = None;
|
||||||
|
let mut justification = None;
|
||||||
|
let mut options = arguments.into_iter();
|
||||||
|
while let Some(option) = options.next() {
|
||||||
|
let value = options
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| io::Error::other("client option has no value"))?;
|
||||||
|
match option.to_str() {
|
||||||
|
Some("--inbox") => inbox = Some(PathBuf::from(value)),
|
||||||
|
Some("--outbox") => outbox = Some(PathBuf::from(value)),
|
||||||
|
Some("--publication") => publication = Some(PathBuf::from(value)),
|
||||||
|
Some("--sandbox-credentials") => sandbox_credentials = Some(PathBuf::from(value)),
|
||||||
|
Some("--registration") => registration = Some(PathBuf::from(value)),
|
||||||
|
Some("--session-binding") => session_binding = Some(PathBuf::from(value)),
|
||||||
|
Some("--justification") => {
|
||||||
|
justification = Some(
|
||||||
|
value
|
||||||
|
.into_string()
|
||||||
|
.map_err(|_| io::Error::other("justification is not UTF-8"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => return Err(io::Error::other("unknown client option")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Arguments {
|
||||||
|
inbox: inbox.ok_or_else(|| io::Error::other("inbox path is missing"))?,
|
||||||
|
outbox: outbox.ok_or_else(|| io::Error::other("outbox path is missing"))?,
|
||||||
|
publication: publication.ok_or_else(|| io::Error::other("publication path is missing"))?,
|
||||||
|
sandbox_credentials: sandbox_credentials
|
||||||
|
.ok_or_else(|| io::Error::other("sandbox credentials path is missing"))?,
|
||||||
|
registration: registration
|
||||||
|
.ok_or_else(|| io::Error::other("registration path is missing"))?,
|
||||||
|
session_binding: session_binding
|
||||||
|
.ok_or_else(|| io::Error::other("session binding path is missing"))?,
|
||||||
|
justification: justification.ok_or_else(|| io::Error::other("justification is missing"))?,
|
||||||
|
command_argv: command.into_iter().map(OsString::into_vec).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::parse_arguments;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
|
||||||
|
fn valid_arguments() -> Vec<OsString> {
|
||||||
|
[
|
||||||
|
"--inbox",
|
||||||
|
"/inbox",
|
||||||
|
"--outbox",
|
||||||
|
"/outbox",
|
||||||
|
"--publication",
|
||||||
|
"/publication",
|
||||||
|
"--sandbox-credentials",
|
||||||
|
"/credentials",
|
||||||
|
"--registration",
|
||||||
|
"/registration",
|
||||||
|
"--session-binding",
|
||||||
|
"/binding",
|
||||||
|
"--justification",
|
||||||
|
"needed",
|
||||||
|
"--",
|
||||||
|
"/usr/bin/tool",
|
||||||
|
"$HOME",
|
||||||
|
"a;b",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(OsString::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_preserves_exact_argv_without_shell_interpretation() {
|
||||||
|
let parsed = parse_arguments(valid_arguments()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed.command_argv,
|
||||||
|
[
|
||||||
|
b"/usr/bin/tool".to_vec(),
|
||||||
|
b"$HOME".to_vec(),
|
||||||
|
b"a;b".to_vec()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_refuses_missing_separator_unknown_options_and_empty_argv() {
|
||||||
|
let mut missing_separator = valid_arguments();
|
||||||
|
missing_separator.retain(|argument| argument != "--");
|
||||||
|
assert!(parse_arguments(missing_separator).is_err());
|
||||||
|
|
||||||
|
let mut unknown = valid_arguments();
|
||||||
|
unknown[0] = OsString::from("--unknown");
|
||||||
|
assert!(parse_arguments(unknown).is_err());
|
||||||
|
|
||||||
|
let mut empty = valid_arguments();
|
||||||
|
empty.truncate(empty.iter().position(|argument| argument == "--").unwrap() + 1);
|
||||||
|
assert!(parse_arguments(empty).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,13 @@ time.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "=3.27.0"
|
tempfile = "=3.27.0"
|
||||||
|
lazaret-escalation-client = { version = "=0.0.0", path = "../lazaret-escalation-client" }
|
||||||
x509-parser.workspace = true
|
x509-parser.workspace = true
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "escalation"
|
||||||
|
path = "tests/escalation.rs"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
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.
|
/// Transfers the sandbox credential package once without exposing coordinator or issuer keys.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # 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 control;
|
||||||
pub mod domain_store;
|
pub mod domain_store;
|
||||||
|
pub mod escalation;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod state_anchor;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,299 @@
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{self, Seek, SeekFrom, Write};
|
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||||
use std::os::fd::AsFd;
|
use std::os::fd::{AsFd, OwnedFd};
|
||||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use lazaret_core::identity::{DeviceId, InodeId, MountId, MountIdentity, NamespaceIdentity};
|
use lazaret_core::identity::{DeviceId, InodeId, MountId, MountIdentity, NamespaceIdentity};
|
||||||
use rustix::fs::{
|
use rustix::fs::{
|
||||||
AtFlags, FlockOperation, MemfdFlags, SealFlags, StatxFlags, fcntl_add_seals, fcntl_get_seals,
|
AtFlags, FlockOperation, MemfdFlags, Mode, OFlags, RawDir, RenameFlags, SealFlags, StatxFlags,
|
||||||
flock, fstat, makedev, memfd_create, statx,
|
fchmod, fcntl_add_seals, fcntl_get_seals, flock, fstat, makedev, memfd_create, openat,
|
||||||
|
renameat_with, statat, statx, unlinkat,
|
||||||
};
|
};
|
||||||
|
|
||||||
static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MailboxEntryIdentity {
|
||||||
|
device: u64,
|
||||||
|
inode: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RegularFileMailbox {
|
||||||
|
directory: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsFd for RegularFileMailbox {
|
||||||
|
fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
|
||||||
|
self.directory.as_fd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegularFileMailbox {
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses entries that are not invoking-user-owned mode-0700 directories opened without links.
|
||||||
|
pub fn openat_private(
|
||||||
|
parent: impl AsFd,
|
||||||
|
name: &std::ffi::OsStr,
|
||||||
|
invoking_uid: u32,
|
||||||
|
) -> io::Result<Self> {
|
||||||
|
validate_mailbox_name(name)?;
|
||||||
|
let directory = File::from(openat(
|
||||||
|
parent.as_fd(),
|
||||||
|
name,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::empty(),
|
||||||
|
)?);
|
||||||
|
Self::from_private_directory(directory, invoking_uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses paths that are not invoking-user-owned mode-0700 directories opened without links.
|
||||||
|
pub fn open_private(path: &Path, invoking_uid: u32) -> io::Result<Self> {
|
||||||
|
let directory = File::from(openat(
|
||||||
|
rustix::fs::CWD,
|
||||||
|
path,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::empty(),
|
||||||
|
)?);
|
||||||
|
Self::from_private_directory(directory, invoking_uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_private_directory(directory: File, invoking_uid: u32) -> io::Result<Self> {
|
||||||
|
let metadata = directory.metadata()?;
|
||||||
|
if !metadata.file_type().is_dir()
|
||||||
|
|| metadata.uid() != invoking_uid
|
||||||
|
|| metadata.mode() & 0o7777 != 0o700
|
||||||
|
{
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"mailbox directory is not invoking-user-owned mode 0700",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self { directory })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses a path that is not a directory opened without following its final component.
|
||||||
|
pub fn open(path: &Path) -> io::Result<Self> {
|
||||||
|
let directory = File::from(openat(
|
||||||
|
rustix::fs::CWD,
|
||||||
|
path,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::empty(),
|
||||||
|
)?);
|
||||||
|
Ok(Self { directory })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes one complete regular-file record using a no-replace atomic rename.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses invalid names, existing destinations, or incomplete durable publication.
|
||||||
|
pub fn publish(
|
||||||
|
&self,
|
||||||
|
name: &std::ffi::OsStr,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> io::Result<MailboxEntryIdentity> {
|
||||||
|
validate_mailbox_name(name)?;
|
||||||
|
let temporary_name = loop {
|
||||||
|
let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let candidate = std::ffi::OsString::from_vec(
|
||||||
|
format!(".lazaret-{}-{sequence}.tmp", std::process::id()).into_bytes(),
|
||||||
|
);
|
||||||
|
match openat(
|
||||||
|
self.directory.as_fd(),
|
||||||
|
&candidate,
|
||||||
|
OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::RUSR | Mode::WUSR,
|
||||||
|
) {
|
||||||
|
Ok(fd) => break (candidate, fd),
|
||||||
|
Err(rustix::io::Errno::EXIST) => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (temporary_name, fd) = temporary_name;
|
||||||
|
let mut file = File::from(fd);
|
||||||
|
let result = (|| {
|
||||||
|
fchmod(file.as_fd(), Mode::RUSR | Mode::WUSR)?;
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
let metadata = file.metadata()?;
|
||||||
|
if !metadata.file_type().is_file()
|
||||||
|
|| metadata.nlink() != 1
|
||||||
|
|| usize::try_from(metadata.len()).ok() != Some(bytes.len())
|
||||||
|
{
|
||||||
|
return Err(io::Error::other("mailbox temporary record changed"));
|
||||||
|
}
|
||||||
|
renameat_with(
|
||||||
|
self.directory.as_fd(),
|
||||||
|
&temporary_name,
|
||||||
|
self.directory.as_fd(),
|
||||||
|
name,
|
||||||
|
RenameFlags::NOREPLACE,
|
||||||
|
)?;
|
||||||
|
self.directory.sync_all()?;
|
||||||
|
Ok(MailboxEntryIdentity {
|
||||||
|
device: metadata.dev(),
|
||||||
|
inode: metadata.ino(),
|
||||||
|
})
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = unlinkat(self.directory.as_fd(), &temporary_name, AtFlags::empty());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and removes one exact regular-file entry after bounding it before allocation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses links, non-regular files, oversize records, replacement, growth, or short reads.
|
||||||
|
pub fn consume(&self, name: &std::ffi::OsStr, maximum: usize) -> io::Result<Vec<u8>> {
|
||||||
|
let (bytes, identity) = self.read(name, maximum)?;
|
||||||
|
self.remove_if_matches(name, identity)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one exact regular-file entry after bounding it before allocation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses links, non-regular files, oversize records, replacement, growth, or short reads.
|
||||||
|
pub fn read(
|
||||||
|
&self,
|
||||||
|
name: &std::ffi::OsStr,
|
||||||
|
maximum: usize,
|
||||||
|
) -> io::Result<(Vec<u8>, MailboxEntryIdentity)> {
|
||||||
|
validate_mailbox_name(name)?;
|
||||||
|
let mut file = File::from(openat(
|
||||||
|
self.directory.as_fd(),
|
||||||
|
name,
|
||||||
|
OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::empty(),
|
||||||
|
)?);
|
||||||
|
let before = file.metadata()?;
|
||||||
|
let length = usize::try_from(before.len())
|
||||||
|
.map_err(|_| io::Error::other("mailbox record length is out of range"))?;
|
||||||
|
if !before.file_type().is_file() || before.nlink() != 1 {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"mailbox entry is linked or is not a regular file",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if length > maximum {
|
||||||
|
return Err(io::Error::other("mailbox record exceeds the size limit"));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::with_capacity(length.saturating_add(1));
|
||||||
|
Read::by_ref(&mut file)
|
||||||
|
.take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1))
|
||||||
|
.read_to_end(&mut bytes)?;
|
||||||
|
let after = file.metadata()?;
|
||||||
|
if bytes.len() != length
|
||||||
|
|| after.dev() != before.dev()
|
||||||
|
|| after.ino() != before.ino()
|
||||||
|
|| after.len() != before.len()
|
||||||
|
|| after.nlink() != 1
|
||||||
|
|| !after.file_type().is_file()
|
||||||
|
{
|
||||||
|
return Err(io::Error::other("mailbox record changed while being read"));
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
bytes,
|
||||||
|
MailboxEntryIdentity {
|
||||||
|
device: before.dev(),
|
||||||
|
inode: before.ino(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a host-published record only while the pathname retains its exact identity.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses a missing or replaced pathname.
|
||||||
|
pub fn remove_if_matches(
|
||||||
|
&self,
|
||||||
|
name: &std::ffi::OsStr,
|
||||||
|
expected: MailboxEntryIdentity,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
validate_mailbox_name(name)?;
|
||||||
|
let quarantine = loop {
|
||||||
|
let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let candidate = std::ffi::OsString::from_vec(
|
||||||
|
format!(".lazaret-remove-{}-{sequence}.tmp", std::process::id()).into_bytes(),
|
||||||
|
);
|
||||||
|
match renameat_with(
|
||||||
|
self.directory.as_fd(),
|
||||||
|
name,
|
||||||
|
self.directory.as_fd(),
|
||||||
|
&candidate,
|
||||||
|
RenameFlags::NOREPLACE,
|
||||||
|
) {
|
||||||
|
Ok(()) => break candidate,
|
||||||
|
Err(rustix::io::Errno::EXIST) => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let current = statat(
|
||||||
|
self.directory.as_fd(),
|
||||||
|
&quarantine,
|
||||||
|
AtFlags::SYMLINK_NOFOLLOW,
|
||||||
|
)?;
|
||||||
|
if current.st_dev != expected.device || current.st_ino != expected.inode {
|
||||||
|
let restore = renameat_with(
|
||||||
|
self.directory.as_fd(),
|
||||||
|
&quarantine,
|
||||||
|
self.directory.as_fd(),
|
||||||
|
name,
|
||||||
|
RenameFlags::NOREPLACE,
|
||||||
|
);
|
||||||
|
return match restore {
|
||||||
|
Ok(()) => Err(io::Error::other("mailbox entry identity mismatch")),
|
||||||
|
Err(error) => Err(io::Error::other(format!(
|
||||||
|
"mailbox entry identity mismatch and restore failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
unlinkat(self.directory.as_fd(), &quarantine, AtFlags::empty())?;
|
||||||
|
self.directory.sync_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the retained directory cannot be enumerated.
|
||||||
|
pub fn names(&self) -> io::Result<Vec<std::ffi::OsString>> {
|
||||||
|
let fd: OwnedFd = openat(
|
||||||
|
self.directory.as_fd(),
|
||||||
|
".",
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::empty(),
|
||||||
|
)?;
|
||||||
|
let mut buffer = vec![std::mem::MaybeUninit::<u8>::uninit(); 8192];
|
||||||
|
let mut directory = RawDir::new(fd, &mut buffer);
|
||||||
|
let mut names = Vec::new();
|
||||||
|
while let Some(entry) = directory.next() {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name().to_bytes();
|
||||||
|
if name != b"." && name != b".." {
|
||||||
|
names.push(std::ffi::OsString::from_vec(name.to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_mailbox_name(name: &std::ffi::OsStr) -> io::Result<()> {
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
if bytes.is_empty()
|
||||||
|
|| bytes.len() > 255
|
||||||
|
|| bytes == b"."
|
||||||
|
|| bytes == b".."
|
||||||
|
|| bytes.contains(&0)
|
||||||
|
|| bytes.contains(&b'/')
|
||||||
|
{
|
||||||
|
Err(io::Error::other("invalid mailbox entry name"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const REQUIRED_SEALS: SealFlags = SealFlags::SHRINK
|
const REQUIRED_SEALS: SealFlags = SealFlags::SHRINK
|
||||||
.union(SealFlags::GROW)
|
.union(SealFlags::GROW)
|
||||||
.union(SealFlags::WRITE)
|
.union(SealFlags::WRITE)
|
||||||
|
|
@ -159,12 +440,13 @@ pub fn require_mount_identity(path: &Path, expected: MountIdentity) -> io::Resul
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
use rustix::fs::{MemfdFlags, memfd_create};
|
use rustix::fs::{MemfdFlags, memfd_create};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
atomic_replace, current_mount_identity, namespace_identity, require_mount_identity,
|
RegularFileMailbox, atomic_replace, current_mount_identity, namespace_identity,
|
||||||
require_write_sealed, sealed_memfd, try_exclusive_flock,
|
require_mount_identity, require_write_sealed, sealed_memfd, try_exclusive_flock,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn test_directory(name: &str) -> std::path::PathBuf {
|
fn test_directory(name: &str) -> std::path::PathBuf {
|
||||||
|
|
@ -265,4 +547,69 @@ mod tests {
|
||||||
assert!(require_mount_identity(&old, expected).is_ok());
|
assert!(require_mount_identity(&old, expected).is_ok());
|
||||||
std::fs::remove_dir_all(directory).unwrap();
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_publication_is_atomic_and_no_replace() {
|
||||||
|
let directory = test_directory("mailbox-publish");
|
||||||
|
let mailbox = RegularFileMailbox::open(&directory).unwrap();
|
||||||
|
let name = std::ffi::OsStr::new("request.ready");
|
||||||
|
mailbox.publish(name, b"complete").unwrap();
|
||||||
|
assert!(mailbox.publish(name, b"replacement").is_err());
|
||||||
|
assert_eq!(std::fs::read(directory.join(name)).unwrap(), b"complete");
|
||||||
|
assert!(
|
||||||
|
mailbox
|
||||||
|
.names()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|entry| !entry.as_encoded_bytes().ends_with(b".tmp"))
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_refuses_links_nonregular_and_oversize_records() {
|
||||||
|
let directory = test_directory("mailbox-hostile");
|
||||||
|
let mailbox = RegularFileMailbox::open(&directory).unwrap();
|
||||||
|
std::fs::write(directory.join("target"), b"target").unwrap();
|
||||||
|
symlink("target", directory.join("symlink.ready")).unwrap();
|
||||||
|
std::fs::hard_link(directory.join("target"), directory.join("hardlink.ready")).unwrap();
|
||||||
|
std::fs::create_dir(directory.join("directory.ready")).unwrap();
|
||||||
|
std::fs::write(directory.join("oversize.ready"), [0_u8; 17]).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
mailbox
|
||||||
|
.consume(std::ffi::OsStr::new("symlink.ready"), 16)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mailbox
|
||||||
|
.consume(std::ffi::OsStr::new("hardlink.ready"), 16)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mailbox
|
||||||
|
.consume(std::ffi::OsStr::new("directory.ready"), 16)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mailbox
|
||||||
|
.consume(std::ffi::OsStr::new("oversize.ready"), 16)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_removes_only_the_identity_it_read() {
|
||||||
|
let directory = test_directory("mailbox-replacement");
|
||||||
|
let mailbox = RegularFileMailbox::open(&directory).unwrap();
|
||||||
|
let name = std::ffi::OsStr::new("result.ready");
|
||||||
|
let identity = mailbox.publish(name, b"first").unwrap();
|
||||||
|
std::fs::rename(directory.join(name), directory.join("old")).unwrap();
|
||||||
|
std::fs::write(directory.join(name), b"foreign").unwrap();
|
||||||
|
assert!(mailbox.remove_if_matches(name, identity).is_err());
|
||||||
|
assert_eq!(std::fs::read(directory.join(name)).unwrap(), b"foreign");
|
||||||
|
std::fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
|
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ use lazaret_core::records::{
|
||||||
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
|
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
|
||||||
};
|
};
|
||||||
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
||||||
use rustix::fs::{fstat, fstatfs};
|
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl, fstat, fstatfs};
|
||||||
use rustix::io::{FdFlags, fcntl_setfd};
|
use rustix::io::{FdFlags, fcntl_setfd};
|
||||||
use rustix::pipe::{PipeFlags, pipe_with};
|
use rustix::pipe::{PipeFlags, pipe_with};
|
||||||
use rustix::process::{Pid, PidfdFlags, Signal, WaitId, WaitIdOptions};
|
use rustix::process::{Pid, PidfdFlags, Signal, WaitId, WaitIdOptions};
|
||||||
|
|
@ -25,11 +25,12 @@ const WRAPPER_ROLE: &str = "launch-wrapper";
|
||||||
const SIGCHLD_PROBE_ROLE: &str = "sigchld-probe";
|
const SIGCHLD_PROBE_ROLE: &str = "sigchld-probe";
|
||||||
static CHILD_BROKER: OnceLock<ChildBroker> = OnceLock::new();
|
static CHILD_BROKER: OnceLock<ChildBroker> = OnceLock::new();
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Debug)]
|
||||||
pub struct TargetCommand {
|
pub struct TargetCommand {
|
||||||
program: OsString,
|
program: OsString,
|
||||||
arguments: Vec<OsString>,
|
arguments: Vec<OsString>,
|
||||||
parent_edge: ParentEdge,
|
parent_edge: ParentEdge,
|
||||||
|
executable: Option<OwnedFd>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
|
@ -45,9 +46,27 @@ impl TargetCommand {
|
||||||
program: program.into(),
|
program: program.into(),
|
||||||
arguments: Vec::new(),
|
arguments: Vec::new(),
|
||||||
parent_edge: ParentEdge::ParentDeathKill,
|
parent_edge: ParentEdge::ParentDeathKill,
|
||||||
|
executable: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Constructs an argv-only target bound to the exact inherited executable descriptor.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the descriptor cannot be made inheritable by the launch wrapper.
|
||||||
|
pub fn from_executable(
|
||||||
|
executable: OwnedFd,
|
||||||
|
arguments: impl IntoIterator<Item = OsString>,
|
||||||
|
) -> io::Result<Self> {
|
||||||
|
fcntl_setfd(executable.as_fd(), FdFlags::empty())?;
|
||||||
|
Ok(Self {
|
||||||
|
program: OsString::from(format!("/proc/self/fd/{}", executable.as_raw_fd())),
|
||||||
|
arguments: arguments.into_iter().collect(),
|
||||||
|
parent_edge: ParentEdge::ParentDeathKill,
|
||||||
|
executable: Some(executable),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
|
pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
|
||||||
self.arguments.push(argument.into());
|
self.arguments.push(argument.into());
|
||||||
|
|
@ -73,6 +92,7 @@ impl TargetCommand {
|
||||||
|
|
||||||
#[allow(clippy::disallowed_types)]
|
#[allow(clippy::disallowed_types)]
|
||||||
fn append_to(&self, command: &mut std::process::Command) {
|
fn append_to(&self, command: &mut std::process::Command) {
|
||||||
|
let _ = &self.executable;
|
||||||
command
|
command
|
||||||
.arg("--parent-action")
|
.arg("--parent-action")
|
||||||
.arg(match self.parent_edge {
|
.arg(match self.parent_edge {
|
||||||
|
|
@ -149,6 +169,35 @@ pub struct RetainedProcess {
|
||||||
identity: PidfsProcessIdentityV1,
|
identity: PidfsProcessIdentityV1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CapturedProcess {
|
||||||
|
process: RetainedProcess,
|
||||||
|
stdout: File,
|
||||||
|
stderr: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapturedProcess {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn process(&self) -> &RetainedProcess {
|
||||||
|
&self.process
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn streams(&mut self) -> (&mut File, &mut File) {
|
||||||
|
(&mut self.stdout, &mut self.stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_process(self) -> RetainedProcess {
|
||||||
|
self.process
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ProcessExit {
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
pub terminating_signal: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
impl RetainedProcess {
|
impl RetainedProcess {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn identity(&self) -> PidfsProcessIdentityV1 {
|
pub const fn identity(&self) -> PidfsProcessIdentityV1 {
|
||||||
|
|
@ -212,6 +261,16 @@ fn require_parent_edge_standard_io(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn require_captured_parent_edge(parent_edge: ParentEdge) -> io::Result<()> {
|
||||||
|
if parent_edge == ParentEdge::ParentDeathKill {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other(
|
||||||
|
"captured output requires the parent-death edge",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ChildBroker {
|
impl ChildBroker {
|
||||||
/// Spawns the repository wrapper, stages exact ownership, accepts its acknowledgment,
|
/// Spawns the repository wrapper, stages exact ownership, accepts its acknowledgment,
|
||||||
/// and only then releases target execution.
|
/// and only then releases target execution.
|
||||||
|
|
@ -312,18 +371,144 @@ impl ChildBroker {
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquires an exact child with bounded-output pipe ownership retained by the caller.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns an error when serialization or exact pidfd reaping fails.
|
/// Refuses and reaps the blocked wrapper on every failed acquisition or callback.
|
||||||
#[allow(clippy::disallowed_methods)]
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
pub fn reap(&self, process: RetainedProcess) -> io::Result<()> {
|
pub fn acquire_captured<Stage, Acknowledge>(
|
||||||
|
&self,
|
||||||
|
expected_parent: PidfsProcessIdentityV1,
|
||||||
|
ownership: ProcessOwnershipContext,
|
||||||
|
target: &TargetCommand,
|
||||||
|
stage: Stage,
|
||||||
|
acknowledge: Acknowledge,
|
||||||
|
) -> io::Result<CapturedProcess>
|
||||||
|
where
|
||||||
|
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
|
||||||
|
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
|
||||||
|
{
|
||||||
let _guard = self
|
let _guard = self
|
||||||
.exclusion
|
.exclusion
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
||||||
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
|
require_captured_parent_edge(target.parent_edge())?;
|
||||||
drop(process);
|
require_default_sigchld()?;
|
||||||
Ok(())
|
require_reapable_sigchld()?;
|
||||||
|
let (release, wait) = launch_barrier()?;
|
||||||
|
let mut command = private_role_command(WRAPPER_ROLE);
|
||||||
|
append_parent_identity(&mut command, expected_parent);
|
||||||
|
target.append_to(&mut command);
|
||||||
|
command
|
||||||
|
.stdin(wait.into_stdio())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = command.spawn()?;
|
||||||
|
let host_pid = child.id();
|
||||||
|
let process = match acquire_spawned_child(host_pid) {
|
||||||
|
Ok(process) => process,
|
||||||
|
Err(error) => return fail_before_pidfd(release, &mut child, error),
|
||||||
|
};
|
||||||
|
let staged = StagedProcessOwnership::v1(
|
||||||
|
ownership.domain_id,
|
||||||
|
ownership.generation_id,
|
||||||
|
ownership.coordinator_id,
|
||||||
|
ownership.slot,
|
||||||
|
process.identity,
|
||||||
|
);
|
||||||
|
if let Err(error) = stage(staged) {
|
||||||
|
return fail_after_pidfd(release, process, error);
|
||||||
|
}
|
||||||
|
let acknowledgment = match acknowledge(staged) {
|
||||||
|
Ok(acknowledgment) => acknowledgment,
|
||||||
|
Err(error) => return fail_after_pidfd(release, process, error),
|
||||||
|
};
|
||||||
|
if !acknowledgment.acknowledges(&staged) {
|
||||||
|
return fail_after_pidfd(
|
||||||
|
release,
|
||||||
|
process,
|
||||||
|
io::Error::other(
|
||||||
|
"process ownership acknowledgment does not match staged ownership",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Err(error) = release.release() {
|
||||||
|
return fail_after_pidfd_closed(process, error);
|
||||||
|
}
|
||||||
|
let Some(stdout) = child.stdout.take() else {
|
||||||
|
return fail_running_captured(
|
||||||
|
process,
|
||||||
|
child,
|
||||||
|
io::Error::other("captured child stdout is unavailable"),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let Some(stderr) = child.stderr.take() else {
|
||||||
|
return fail_running_captured(
|
||||||
|
process,
|
||||||
|
child,
|
||||||
|
io::Error::other("captured child stderr is unavailable"),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let stdout = match child_pipe_file(stdout.into()) {
|
||||||
|
Ok(stdout) => stdout,
|
||||||
|
Err(error) => return fail_running_captured(process, child, error),
|
||||||
|
};
|
||||||
|
let stderr = match child_pipe_file(stderr.into()) {
|
||||||
|
Ok(stderr) => stderr,
|
||||||
|
Err(error) => return fail_running_captured(process, child, error),
|
||||||
|
};
|
||||||
|
drop(child);
|
||||||
|
Ok(CapturedProcess {
|
||||||
|
process,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when serialization or exact pidfd reaping fails.
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
pub fn reap(&self, process: RetainedProcess) -> io::Result<()> {
|
||||||
|
self.reap_status(process).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when serialization or exact pidfd reaping fails.
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
pub fn reap_status(&self, process: RetainedProcess) -> io::Result<ProcessExit> {
|
||||||
|
let _guard = self
|
||||||
|
.exclusion
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
||||||
|
let status =
|
||||||
|
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?
|
||||||
|
.ok_or_else(|| io::Error::other("exact child exited without wait status"))?;
|
||||||
|
let exit = ProcessExit {
|
||||||
|
exit_code: status.exit_status(),
|
||||||
|
terminating_signal: status.terminating_signal(),
|
||||||
|
};
|
||||||
|
drop(process);
|
||||||
|
Ok(exit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child_pipe_file(fd: OwnedFd) -> io::Result<File> {
|
||||||
|
let flags = fcntl_getfl(fd.as_fd())?;
|
||||||
|
fcntl_setfl(fd.as_fd(), flags | OFlags::NONBLOCK)?;
|
||||||
|
Ok(File::from(fd))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
|
fn fail_running_captured<T>(
|
||||||
|
process: RetainedProcess,
|
||||||
|
child: std::process::Child,
|
||||||
|
error: io::Error,
|
||||||
|
) -> io::Result<T> {
|
||||||
|
let _ = process.send_signal(Signal::KILL);
|
||||||
|
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
|
||||||
|
drop(child);
|
||||||
|
drop(process);
|
||||||
|
Err(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::disallowed_types)]
|
#[allow(clippy::disallowed_types)]
|
||||||
|
|
@ -429,30 +614,27 @@ pub fn verify_direct_child_pidfd(pidfd: impl AsFd) -> io::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
fn fail_before_pidfd(
|
fn fail_before_pidfd<T>(
|
||||||
release: BarrierRelease,
|
release: BarrierRelease,
|
||||||
child: &mut std::process::Child,
|
child: &mut std::process::Child,
|
||||||
error: io::Error,
|
error: io::Error,
|
||||||
) -> io::Result<RetainedProcess> {
|
) -> io::Result<T> {
|
||||||
drop(release);
|
drop(release);
|
||||||
child.wait()?;
|
child.wait()?;
|
||||||
Err(error)
|
Err(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fail_after_pidfd(
|
fn fail_after_pidfd<T>(
|
||||||
release: BarrierRelease,
|
release: BarrierRelease,
|
||||||
process: RetainedProcess,
|
process: RetainedProcess,
|
||||||
error: io::Error,
|
error: io::Error,
|
||||||
) -> io::Result<RetainedProcess> {
|
) -> io::Result<T> {
|
||||||
drop(release);
|
drop(release);
|
||||||
fail_after_pidfd_closed(process, error)
|
fail_after_pidfd_closed(process, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::disallowed_methods)]
|
#[allow(clippy::disallowed_methods)]
|
||||||
fn fail_after_pidfd_closed(
|
fn fail_after_pidfd_closed<T>(process: RetainedProcess, error: io::Error) -> io::Result<T> {
|
||||||
process: RetainedProcess,
|
|
||||||
error: io::Error,
|
|
||||||
) -> io::Result<RetainedProcess> {
|
|
||||||
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
|
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
|
||||||
drop(process);
|
drop(process);
|
||||||
Err(error)
|
Err(error)
|
||||||
|
|
@ -848,6 +1030,7 @@ pub fn parse_wrapper_arguments(
|
||||||
program,
|
program,
|
||||||
arguments: arguments.collect(),
|
arguments: arguments.collect(),
|
||||||
parent_edge,
|
parent_edge,
|
||||||
|
executable: None,
|
||||||
};
|
};
|
||||||
let boot = parse_boot_hex(&boot)?;
|
let boot = parse_boot_hex(&boot)?;
|
||||||
Ok((
|
Ok((
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,14 @@ PROCESS_LINT_ALLOWANCES = {
|
||||||
"send_signal",
|
"send_signal",
|
||||||
"acquire",
|
"acquire",
|
||||||
"acquire_with_stdio",
|
"acquire_with_stdio",
|
||||||
|
"acquire_captured",
|
||||||
"private_role_command",
|
"private_role_command",
|
||||||
"require_reapable_sigchld",
|
"require_reapable_sigchld",
|
||||||
"cleanup_sigchld_probe",
|
"cleanup_sigchld_probe",
|
||||||
"fail_sigchld_probe",
|
"fail_sigchld_probe",
|
||||||
"reap",
|
"reap",
|
||||||
|
"reap_status",
|
||||||
|
"fail_running_captured",
|
||||||
"acquire_spawned_child",
|
"acquire_spawned_child",
|
||||||
"verify_direct_child_pidfd",
|
"verify_direct_child_pidfd",
|
||||||
"fail_before_pidfd",
|
"fail_before_pidfd",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue