Decouple client contracts and close private role registries

This commit is contained in:
ms2k 2026-07-19 03:44:30 +09:00
commit 22325aa76a
45 changed files with 1041 additions and 321 deletions

View file

@ -13,10 +13,10 @@ path = "src/main.rs"
clap.workspace = true
ed25519-dalek.workspace = true
getrandom.workspace = true
lazaret-control-client = { version = "=0.0.0", path = "../lazaret-control-client" }
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
lazaret-host-coordinator = { version = "=0.0.0", path = "../lazaret-host-coordinator" }
lazaret-domain-state = { version = "=0.0.0", path = "../lazaret-domain-state" }
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-supervisor" }
rustix.workspace = true
rustls.workspace = true
sha2.workspace = true

View file

@ -2,7 +2,7 @@ use std::ffi::OsString;
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use lazaret_host_coordinator::domain_store::StateResetScopeV1;
use lazaret_core::records::StateResetScopeV1;
use crate::ClientCommand;
use crate::state::{StateCommand, StateOperation};

View file

@ -8,19 +8,17 @@ use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
use std::time::Duration;
use lazaret_control_client::{ControlClient, ControlClientError};
use lazaret_core::configuration::{
ConfigurationLoadRequest, ResolvedDomainConfiguration, load_trusted_configuration,
};
use lazaret_host_coordinator::client_bootstrap::PRIVATE_CLIENT_BOOTSTRAP_ROLE;
use lazaret_host_coordinator::control::{ControlError, PublishedControlFace};
use lazaret_host_coordinator::waiter::{
BOOTSTRAP_WAITER_SOCKET_NAME, BootstrapWaitOutcome, BootstrapWaitRequest, read_frame,
write_frame,
use lazaret_core::protocol::{
BOOTSTRAP_WAITER_SOCKET_NAME, BootstrapWaitOutcome, BootstrapWaitRequest,
PRIVATE_CLIENT_BOOTSTRAP_ROLE, read_waiter_frame, write_waiter_frame,
};
use lazaret_linux::filesystem::try_exclusive_flock;
use lazaret_linux::platform::{PlatformObservation, check_platform};
use lazaret_linux::process::{BootstrapChild, TargetCommand, child_broker};
use lazaret_runtime_supervisor::sandbox::default_sandbox_layout;
use rustix::fs::{Mode, OFlags, openat};
use rustix::pipe::{PipeFlags, pipe_with};
@ -80,7 +78,7 @@ impl CoordinatorClientBackend {
fn discover(&self) -> Result<Discovery, CoordinatorClientError> {
let configuration = self.configuration()?;
match PublishedControlFace::discover_confined(
match ControlClient::discover_confined(
configuration.runtime_domain_root(),
rustix::process::getuid().as_raw(),
) {
@ -96,7 +94,7 @@ impl CoordinatorClientBackend {
authority,
))))
}
Err(ControlError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
Err(ControlClientError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
Ok(Discovery::Absent)
}
Ok(_) | Err(_) => Ok(Discovery::Stale),
@ -292,17 +290,17 @@ impl CoordinatorClientBackend {
.set_read_timeout(Some(observation_remaining(observer)?))
.map_err(failure)
.map_err(ClientError::Backend)?;
write_frame(&mut stream, request).map_err(|error| {
write_waiter_frame(&mut stream, request).map_err(|error| {
ClientError::Backend(failure(format!("bootstrap waiter request failed: {error}")))
})?;
let first: BootstrapWaitOutcome = read_frame(&mut stream).map_err(|error| {
let first: BootstrapWaitOutcome = read_waiter_frame(&mut stream).map_err(|error| {
ClientError::Backend(failure(format!(
"bootstrap waiter admission failed: {error}"
)))
})?;
let mut action = observer.waiter(first, false);
if action == WaiterAction::AwaitTerminal {
action = match read_frame(&mut stream) {
action = match read_waiter_frame(&mut stream) {
Ok(outcome) => observer.waiter(outcome, true),
Err(_) => WaiterAction::RefuseFailed,
};
@ -465,7 +463,7 @@ pub(crate) fn load_configuration(
xdg_data_home: optional_environment_path("XDG_DATA_HOME"),
xdg_runtime_dir: required_environment_path("XDG_RUNTIME_DIR")?,
environment: environment(),
sandbox_layout: default_sandbox_layout()?,
sandbox_layout: lazaret_core::configuration::SandboxLayoutV1::default_layout()?,
})?)
}
@ -558,7 +556,7 @@ mod tests {
fs::create_dir(&runtime_path).unwrap();
fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o700)).unwrap();
assert!(
PublishedControlFace::discover_confined(
ControlClient::discover_confined(
runtime_configuration.runtime_domain_root(),
rustix::process::getuid().as_raw(),
)
@ -615,7 +613,7 @@ mod tests {
)
.unwrap();
let (authority, socket, observed) = PublishedControlFace::discover_confined(
let (authority, socket, observed) = ControlClient::discover_confined(
configuration.runtime_domain_root(),
rustix::process::getuid().as_raw(),
)
@ -690,7 +688,7 @@ mod tests {
xdg_data_home: None,
xdg_runtime_dir: runtime,
environment: BTreeMap::new(),
sandbox_layout: default_sandbox_layout().unwrap(),
sandbox_layout: lazaret_core::configuration::SandboxLayoutV1::default_layout().unwrap(),
})
.unwrap()
}

View file

@ -123,12 +123,10 @@ impl<C: ObservationClock> DomainObservation<C> {
pub(crate) fn waiter(
&self,
outcome: lazaret_host_coordinator::waiter::BootstrapWaitOutcome,
outcome: lazaret_core::protocol::BootstrapWaitOutcome,
admitted: bool,
) -> WaiterAction {
use lazaret_host_coordinator::waiter::BootstrapWaitOutcome::{
Failed, Published, Refused, Waiting,
};
use lazaret_core::protocol::BootstrapWaitOutcome::{Failed, Published, Refused, Waiting};
if self.path != ObservationPath::WaitingClient {
return WaiterAction::RefuseProtocol;
}
@ -177,7 +175,7 @@ impl<C: ObservationClock> DomainObservation<C> {
#[cfg(test)]
mod tests {
use super::*;
use lazaret_host_coordinator::waiter::BootstrapWaitOutcome;
use lazaret_core::protocol::BootstrapWaitOutcome;
#[derive(Default)]
struct FakeClock {

View file

@ -5,6 +5,7 @@ use std::path::PathBuf;
use std::sync::Mutex;
use ed25519_dalek::SigningKey;
use lazaret_control_client::{ControlClient, ControlClientError, PendingClientEnrollment};
use lazaret_core::identity::{ClientId, SessionAnchorId, SessionId};
use lazaret_core::records::{
ClientEnrollmentResponse, ControlRequest, ControlResponse, DeveloperDecision,
@ -13,9 +14,6 @@ use lazaret_core::records::{
ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, SessionExitOutcome,
SessionRegistrationRequest, TlsSubjectId,
};
use lazaret_host_coordinator::control::{
ControlError, PendingClientEnrollment, PublishedControlFace,
};
use sha2::{Digest, Sha256};
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
@ -23,6 +21,7 @@ use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive
pub mod cli;
pub mod coordinator_backend;
mod domain_observation;
pub mod private_roles;
pub mod state;
pub mod terminal;
@ -59,7 +58,7 @@ pub enum ClientError<E> {
EmptyCommand,
Backend(E),
Io(io::Error),
Control(ControlError),
Control(ControlClientError),
Protocol(&'static str),
}
@ -302,15 +301,15 @@ pub fn execute_over_control_face_as<E>(
let cli_id = identity.cli_id;
let session_id = identity.session_id;
let pending = PendingClientEnrollment::create(generation.clone(), cli_id)?;
let anonymous = PublishedControlFace::client_config(&endpoint.publication, None)?;
let anonymous = ControlClient::config(&endpoint.publication, None)?;
let mut enrollment =
PublishedControlFace::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?;
PublishedControlFace::send(
ControlClient::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?;
ControlClient::send(
&mut enrollment,
&ControlRequest::Enroll(pending.request().clone()),
)?;
let ControlResponse::Enrolled(ClientEnrollmentResponse { credential, .. }) =
PublishedControlFace::receive(&mut enrollment)?
ControlClient::receive(&mut enrollment)?
else {
return Err(ClientError::Protocol(
"enrollment response was not Enrolled",
@ -331,21 +330,20 @@ pub fn execute_over_control_face_as<E>(
session_id,
session_anchor_public_key: identity.anchor.verifying_key().to_bytes().to_vec(),
};
let authenticated = PublishedControlFace::client_config(
let authenticated = ControlClient::config(
&endpoint.publication,
Some((&credential, pending.private_key())),
)?;
let mut stream =
PublishedControlFace::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?;
PublishedControlFace::send(
ControlClient::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?;
ControlClient::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: binding.clone(),
}),
)?;
let ControlResponse::Registered(registered) = PublishedControlFace::receive(&mut stream)?
else {
let ControlResponse::Registered(registered) = ControlClient::receive(&mut stream)? else {
return Err(ClientError::Protocol(
"registration response was not Registered",
));
@ -356,7 +354,7 @@ pub fn execute_over_control_face_as<E>(
));
}
endpoint.acknowledge_waiter_transfer()?;
PublishedControlFace::send(
ControlClient::send(
&mut stream,
&ControlRequest::Execute(NonInteractiveSessionRequest {
protocol_version: ProtocolVersion::V1,
@ -365,8 +363,8 @@ pub fn execute_over_control_face_as<E>(
}),
)?;
loop {
let message = PublishedControlFace::receive(&mut stream).map_err(|error| match error {
ControlError::Io(_) => ClientError::LostConnection,
let message = ControlClient::receive(&mut stream).map_err(|error| match error {
ControlClientError::Io(_) => ClientError::LostConnection,
other => ClientError::Control(other),
})?;
match message {
@ -412,15 +410,15 @@ pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
let cli_id = identity.cli_id;
let session_id = identity.session_id;
let pending = PendingClientEnrollment::create(generation.clone(), cli_id)?;
let anonymous = PublishedControlFace::client_config(&endpoint.publication, None)?;
let anonymous = ControlClient::config(&endpoint.publication, None)?;
let mut enrollment =
PublishedControlFace::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?;
PublishedControlFace::send(
ControlClient::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?;
ControlClient::send(
&mut enrollment,
&ControlRequest::Enroll(pending.request().clone()),
)?;
let ControlResponse::Enrolled(ClientEnrollmentResponse { credential, .. }) =
PublishedControlFace::receive(&mut enrollment)?
ControlClient::receive(&mut enrollment)?
else {
return Err(ClientError::Protocol(
"enrollment response was not Enrolled",
@ -440,21 +438,20 @@ pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
session_id,
session_anchor_public_key: identity.anchor.verifying_key().to_bytes().to_vec(),
};
let authenticated = PublishedControlFace::client_config(
let authenticated = ControlClient::config(
&endpoint.publication,
Some((&credential, pending.private_key())),
)?;
let mut stream =
PublishedControlFace::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?;
PublishedControlFace::send(
ControlClient::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?;
ControlClient::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: binding.clone(),
}),
)?;
let ControlResponse::Registered(registered) = PublishedControlFace::receive(&mut stream)?
else {
let ControlResponse::Registered(registered) = ControlClient::receive(&mut stream)? else {
return Err(ClientError::Protocol(
"registration response was not Registered",
));
@ -481,7 +478,7 @@ impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
&mut self,
request: InteractiveSessionRequest,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
PublishedControlFace::send(
ControlClient::send(
&mut self.stream,
&ControlRequest::ExecuteInteractive(request),
)?;
@ -492,7 +489,7 @@ impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
PublishedControlFace::send(&mut self.stream, &event).map_err(map_control_connection)?;
ControlClient::send(&mut self.stream, &event).map_err(map_control_connection)?;
receive_interactive_batch(&mut self.stream)
}
}
@ -500,12 +497,12 @@ impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
fn receive_interactive_batch(
stream: &mut impl std::io::Read,
) -> Result<InteractiveSessionBatch, ClientError<std::convert::Infallible>> {
PublishedControlFace::receive(stream).map_err(map_control_connection)
ControlClient::receive(stream).map_err(map_control_connection)
}
fn map_control_connection(error: ControlError) -> ClientError<std::convert::Infallible> {
fn map_control_connection(error: ControlClientError) -> ClientError<std::convert::Infallible> {
match error {
ControlError::Io(_) => ClientError::LostConnection,
ControlClientError::Io(_) => ClientError::LostConnection,
other => ClientError::Control(other),
}
}
@ -547,8 +544,8 @@ impl<E> From<io::Error> for ClientError<E> {
}
}
impl<E> From<ControlError> for ClientError<E> {
fn from(error: ControlError) -> Self {
impl<E> From<ControlClientError> for ClientError<E> {
fn from(error: ControlClientError) -> Self {
Self::Control(error)
}
}

View file

@ -1,8 +1,8 @@
fn main() {
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
if let Some(result) = lazaret_linux::process::dispatch_private_process_role(&arguments) {
if let Err(error) = result {
eprintln!("private process role failed: {error}");
if let Some(outcome) = lazaret_client::private_roles::REGISTRY.dispatch(&arguments) {
if let Err(error) = outcome.result {
eprintln!("private role {} failed: {error}", outcome.name);
std::process::exit(1);
}
return;

View file

@ -0,0 +1,19 @@
use lazaret_linux::private_role::{PrivateRole, PrivateRoleRegistry};
use lazaret_linux::process::{PRIVATE_PROCESS_ROLE, dispatch_private_process_role};
static ROLES: [PrivateRole; 1] = [PrivateRole::new(
PRIVATE_PROCESS_ROLE,
dispatch_private_process_role,
)];
pub static REGISTRY: PrivateRoleRegistry = PrivateRoleRegistry::new(&ROLES);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_declares_only_process_acquisition() {
assert_eq!(REGISTRY.names(), [PRIVATE_PROCESS_ROLE]);
}
}

View file

@ -1,9 +1,9 @@
use std::io::{self, Write};
use std::os::fd::AsFd;
use lazaret_core::records::Domain;
use lazaret_host_coordinator::domain_store::{HostCoordinatorDomainStore, StateResetScopeV1};
use lazaret_host_coordinator::state_maintenance::{
use lazaret_core::records::{Domain, StateResetScopeV1};
use lazaret_domain_state::domain_store::HostCoordinatorDomainStore;
use lazaret_domain_state::state_maintenance::{
DomainStateReport, StateAnchorEntryKind, inspect_absent_domain_state, inspect_domain_state,
reset_domain_state,
};

View file

@ -0,0 +1,17 @@
[package]
name = "lazaret-control-client"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
rcgen.workspace = true
rustix.workspace = true
rustls.workspace = true
serde.workspace = true
sha2.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,411 @@
#![doc = "Dependency-neutral client side of the Lazaret control protocol."]
use std::fmt;
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lazaret_core::codec::{CodecError, decode, encode};
use lazaret_core::configuration::ConfinedPathPlan;
use lazaret_core::identity::{CertificateDigest, ClientId};
use lazaret_core::protocol::{
ProtocolValidationError, client_enrollment_binding, verify_tls_credential,
};
use lazaret_core::records::{
ClientEnrollmentRequest, GenerationIdentity, ProtocolPublicationIdentity, ProtocolVersion,
TlsCredential, TlsRole,
};
use rcgen::{
CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair,
KeyUsagePurpose, PKCS_ED25519,
};
use rustix::fs::{Mode, OFlags, openat};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
use rustls::{ClientConfig, ClientConnection, RootCertStore};
use sha2::{Digest, Sha256};
pub const CONTROL_ALPN: &[u8] = b"lazaret-control/1";
pub const CONTROL_SERVER_NAME: &str = "coordinator.lazaret.invalid";
pub const CONTROL_SOCKET_NAME: &str = "c.sock";
pub const PUBLICATION_IDENTITY_NAME: &str = "protocol-identity.postcard";
pub const MAX_CONTROL_BYTES: usize = 65_536;
#[derive(Debug)]
pub enum ControlClientError {
Io(io::Error),
Codec(CodecError),
Certificate(rcgen::Error),
Tls(rustls::Error),
Protocol(ProtocolValidationError),
InvalidPath(&'static str),
InvalidPublication(&'static str),
InvalidCertificateProfile,
OversizeFrame { length: usize, maximum: usize },
}
impl fmt::Display for ControlClientError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "control I/O failed: {error}"),
Self::Codec(error) => write!(formatter, "control record failed validation: {error}"),
Self::Certificate(error) => write!(formatter, "certificate operation failed: {error}"),
Self::Tls(error) => write!(formatter, "TLS failed: {error}"),
Self::Protocol(error) => write!(formatter, "protocol validation failed: {error}"),
Self::InvalidPath(reason) => write!(formatter, "invalid control path: {reason}"),
Self::InvalidPublication(reason) => {
write!(formatter, "invalid control publication: {reason}")
}
Self::InvalidCertificateProfile => {
formatter.write_str("TLS certificate profile is invalid")
}
Self::OversizeFrame { length, maximum } => {
write!(formatter, "control frame length {length} exceeds {maximum}")
}
}
}
}
impl std::error::Error for ControlClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
Self::Codec(error) => Some(error),
Self::Certificate(error) => Some(error),
Self::Tls(error) => Some(error),
Self::Protocol(error) => Some(error),
Self::InvalidPath(_)
| Self::InvalidPublication(_)
| Self::InvalidCertificateProfile
| Self::OversizeFrame { .. } => None,
}
}
}
impl From<io::Error> for ControlClientError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<CodecError> for ControlClientError {
fn from(error: CodecError) -> Self {
Self::Codec(error)
}
}
impl From<rcgen::Error> for ControlClientError {
fn from(error: rcgen::Error) -> Self {
Self::Certificate(error)
}
}
impl From<rustls::Error> for ControlClientError {
fn from(error: rustls::Error) -> Self {
Self::Tls(error)
}
}
impl From<ProtocolValidationError> for ControlClientError {
fn from(error: ProtocolValidationError) -> Self {
Self::Protocol(error)
}
}
pub struct PendingClientEnrollment {
request: ClientEnrollmentRequest,
private_key: PrivatePkcs8KeyDer<'static>,
}
impl PendingClientEnrollment {
/// Creates a CSR bound to the exact generation and Client CLI identity.
///
/// # Errors
/// Returns an error when encoding, key generation, or CSR signing fails.
pub fn create(
generation_identity: GenerationIdentity,
cli_id: ClientId,
) -> Result<Self, ControlClientError> {
let binding = client_enrollment_binding(&generation_identity, cli_id)?;
let mut params = CertificateParams::default();
params.distinguished_name = DistinguishedName::new();
params.distinguished_name.push(DnType::CommonName, binding);
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
let key = KeyPair::generate_for(&PKCS_ED25519)?;
let csr = params.serialize_request(&key)?;
Ok(Self {
request: ClientEnrollmentRequest {
protocol_version: ProtocolVersion::V1,
generation_identity,
cli_id,
certificate_signing_request_der: csr.der().to_vec(),
},
private_key: PrivatePkcs8KeyDer::from(key.serialize_der()),
})
}
#[must_use]
pub const fn request(&self) -> &ClientEnrollmentRequest {
&self.request
}
#[must_use]
pub fn private_key(&self) -> PrivateKeyDer<'static> {
PrivateKeyDer::Pkcs8(self.private_key.clone_key())
}
}
pub struct ControlClient;
impl ControlClient {
/// Discovers a publication through retained Runtime Domain authority.
///
/// # Errors
/// Refuses changed authority and malformed or non-private publication objects.
pub fn discover_confined(
runtime_domain_root: &ConfinedPathPlan,
uid: u32,
) -> Result<(File, PathBuf, ProtocolPublicationIdentity), ControlClientError> {
let root =
File::from(runtime_domain_root.open_authority().map_err(|_| {
ControlClientError::InvalidPath("runtime Domain authority changed")
})?);
let root_metadata = root.metadata()?;
if !root_metadata.is_dir()
|| root_metadata.uid() != uid
|| root_metadata.mode() & 0o7777 != 0o700
{
return Err(ControlClientError::InvalidPath(
"runtime Domain root is not invoking-user-owned mode 0700",
));
}
let mut identity = File::from(
openat(
root.as_fd(),
PUBLICATION_IDENTITY_NAME,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(io::Error::from)?,
);
let metadata = identity.metadata()?;
if !metadata.file_type().is_file()
|| metadata.uid() != uid
|| metadata.mode() & 0o7777 != 0o600
|| metadata.nlink() != 1
|| metadata.len() > MAX_CONTROL_BYTES as u64
{
return Err(ControlClientError::InvalidPublication(
"publication identity is not an exact private regular file",
));
}
let socket = File::from(
openat(
root.as_fd(),
CONTROL_SOCKET_NAME,
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(io::Error::from)?,
);
let metadata = socket.metadata()?;
if !metadata.file_type().is_socket()
|| metadata.uid() != uid
|| metadata.mode() & 0o7777 != 0o600
{
return Err(ControlClientError::InvalidPublication(
"control socket is not an invoking-user-private socket",
));
}
let mut bytes = Vec::new();
identity.read_to_end(&mut bytes)?;
let socket_path = PathBuf::from(format!(
"/proc/self/fd/{}/{}",
root.as_raw_fd(),
CONTROL_SOCKET_NAME
));
Ok((root, socket_path, decode(&bytes)?))
}
/// Builds a TLS 1.3 client configuration pinned to the publication CA.
///
/// # Errors
/// Refuses malformed CA or client credential material.
pub fn config(
publication: &ProtocolPublicationIdentity,
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig, ControlClientError> {
let mut roots = RootCertStore::empty();
roots.add(CertificateDer::from(
publication.tls_ca_certificate_der.clone(),
))?;
let builder = ClientConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_root_certificates(roots);
let mut config = if let Some((credential, private_key)) = client {
verify_tls_credential(
credential,
&publication.generation_identity,
TlsRole::ClientCli,
credential.subject_id(),
)?;
builder.with_client_auth_cert(
vec![CertificateDer::from(credential.certificate_der.clone())],
private_key,
)?
} else {
builder.with_no_client_auth()
};
config.alpn_protocols = vec![CONTROL_ALPN.to_vec()];
config.enable_early_data = false;
config.resumption = rustls::client::Resumption::disabled();
Ok(config)
}
/// Connects a pinned TLS stream to the control socket.
///
/// # Errors
/// Returns socket, TLS, or certificate-profile failures.
pub fn connect(
socket_path: &Path,
config: ClientConfig,
publication: &ProtocolPublicationIdentity,
) -> Result<
rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>,
ControlClientError,
> {
let socket = std::os::unix::net::UnixStream::connect(socket_path)?;
let server_name = ServerName::try_from(CONTROL_SERVER_NAME)
.map_err(|_| ControlClientError::InvalidCertificateProfile)?;
let mut connection = ClientConnection::new(Arc::new(config), server_name)?;
let mut socket = socket;
while connection.is_handshaking() {
connection.complete_io(&mut socket)?;
}
let [certificate] = connection.peer_certificates().unwrap_or_default() else {
return Err(ControlClientError::InvalidCertificateProfile);
};
if connection.protocol_version() != Some(rustls::ProtocolVersion::TLSv1_3)
|| connection.alpn_protocol() != Some(CONTROL_ALPN)
|| certificate_digest(certificate) != publication.coordinator_tls_certificate_sha256
{
return Err(ControlClientError::InvalidCertificateProfile);
}
Ok(rustls::StreamOwned::new(connection, socket))
}
/// Writes one bounded length-delimited control record.
///
/// # Errors
/// Returns encoding, size, or stream failures.
pub fn send<T: serde::Serialize>(
stream: &mut impl Write,
record: &T,
) -> Result<(), ControlClientError> {
let bytes = encode(record)?;
if bytes.len() > MAX_CONTROL_BYTES {
return Err(ControlClientError::OversizeFrame {
length: bytes.len(),
maximum: MAX_CONTROL_BYTES,
});
}
let length = u32::try_from(bytes.len()).map_err(|_| ControlClientError::OversizeFrame {
length: bytes.len(),
maximum: MAX_CONTROL_BYTES,
})?;
stream.write_all(&length.to_be_bytes())?;
stream.write_all(&bytes)?;
stream.flush()?;
Ok(())
}
/// Reads one bounded length-delimited control record.
///
/// # Errors
/// Returns framing, size, decoding, or stream failures.
pub fn receive<T>(stream: &mut impl Read) -> Result<T, ControlClientError>
where
T: serde::de::DeserializeOwned + serde::Serialize,
{
let mut length = [0_u8; 4];
stream.read_exact(&mut length)?;
let length = u32::from_be_bytes(length) as usize;
if length > MAX_CONTROL_BYTES {
return Err(ControlClientError::OversizeFrame {
length,
maximum: MAX_CONTROL_BYTES,
});
}
let mut bytes = vec![0_u8; length];
stream.read_exact(&mut bytes)?;
Ok(decode(&bytes)?)
}
}
fn certificate_digest(certificate: impl AsRef<[u8]>) -> CertificateDigest {
CertificateDigest::new(sha256(certificate.as_ref()))
}
fn sha256(bytes: &[u8]) -> [u8; 32] {
Sha256::digest(bytes).into()
}
#[cfg(test)]
mod tests {
use lazaret_core::identity::{
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, SandboxId,
};
use lazaret_core::records::{ControlRequest, GenerationIdentity};
use super::*;
#[test]
fn enrollment_request_retains_exact_generation_and_client_identity() {
let generation = GenerationIdentity {
agent_profile: "profile".to_owned(),
domain_id: DomainId::new([1; 32]),
generation_id: GenerationId::new(2),
configuration_fingerprint: ConfigurationFingerprint::new([3; 32]),
coordinator_id: CoordinatorId::new([4; 32]),
sandbox_id: SandboxId::new([5; 32]),
};
let cli_id = ClientId::new([6; 32]);
let pending = PendingClientEnrollment::create(generation.clone(), cli_id).unwrap();
assert_eq!(pending.request().generation_identity, generation);
assert_eq!(pending.request().cli_id, cli_id);
assert!(!pending.request().certificate_signing_request_der.is_empty());
assert!(matches!(pending.private_key(), PrivateKeyDer::Pkcs8(_)));
}
#[test]
fn control_framing_round_trips_and_rejects_oversize_length() {
let request = ControlRequest::Enroll(
PendingClientEnrollment::create(
GenerationIdentity {
agent_profile: "profile".to_owned(),
domain_id: DomainId::new([7; 32]),
generation_id: GenerationId::new(8),
configuration_fingerprint: ConfigurationFingerprint::new([9; 32]),
coordinator_id: CoordinatorId::new([10; 32]),
sandbox_id: SandboxId::new([11; 32]),
},
ClientId::new([12; 32]),
)
.unwrap()
.request()
.clone(),
);
let mut bytes = Vec::new();
ControlClient::send(&mut bytes, &request).unwrap();
assert_eq!(
ControlClient::receive::<ControlRequest>(&mut bytes.as_slice()).unwrap(),
request
);
let oversized = u32::try_from(MAX_CONTROL_BYTES + 1).unwrap().to_be_bytes();
assert!(ControlClient::receive::<ControlRequest>(&mut oversized.as_slice()).is_err());
}
}

View file

@ -128,6 +128,20 @@ impl fmt::Debug for SandboxLayoutV1 {
}
impl SandboxLayoutV1 {
/// Returns the fixed protected destination set shared by all executable roles.
///
/// # Errors
/// Returns an error only if the built-in layout violates the layout contract.
pub fn default_layout() -> Result<Self, ConfigurationError> {
Self::from_protected_destinations([
"/workspace".to_owned(),
"/run/lazaret".to_owned(),
"/proc".to_owned(),
"/sys".to_owned(),
"/dev".to_owned(),
])
}
/// Builds the closed destination set supplied by default sandbox-layout construction.
///
/// # Errors

View file

@ -3,9 +3,11 @@
mod names;
mod session;
mod signing;
mod waiter;
mod window;
pub use names::*;
pub use session::*;
pub use signing::*;
pub use waiter::*;
pub use window::*;

View file

@ -1,6 +1,9 @@
use std::ffi::{OsStr, OsString};
use crate::identity::{RequestId, SessionId};
use crate::codec::{CodecError, encode};
use crate::identity::{ClientId, RequestId, SessionId};
use crate::records::GenerationIdentity;
use sha2::{Digest, Sha256};
fn identifier_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
@ -11,6 +14,21 @@ fn identifier_hex(bytes: &[u8]) -> String {
})
}
/// Returns the certificate subject binding for one generation-scoped Client identity.
///
/// # Errors
/// Returns an error when the identity tuple cannot be encoded.
pub fn client_enrollment_binding(
generation_identity: &GenerationIdentity,
cli_id: ClientId,
) -> Result<String, CodecError> {
let encoded = encode(&(generation_identity, cli_id))?;
Ok(format!(
"lazaret-client-{}",
identifier_hex(&Sha256::digest(encoded))
))
}
#[must_use]
pub fn mailbox_generation_name(generation: u64) -> OsString {
OsString::from(format!("generation-{generation:020}"))

View file

@ -0,0 +1,123 @@
use std::fmt;
use std::io::{Read, Write};
use serde::{Deserialize, Serialize};
use crate::codec;
use crate::identity::{ClientId, ConfigurationFingerprint, DomainId, SessionAnchorId, SessionId};
use crate::records::ProtocolVersion;
pub const BOOTSTRAP_WAITER_SOCKET_NAME: &str = "wait.sock";
pub const PRIVATE_CLIENT_BOOTSTRAP_ROLE: &str = "--lazaret-private-client-bootstrap-v1";
const MAX_WAITER_FRAME_BYTES: usize = 4_096;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct BootstrapWaitRequest {
pub protocol_version: ProtocolVersion,
pub domain_id: DomainId,
pub configuration_fingerprint: ConfigurationFingerprint,
pub cli_id: ClientId,
pub session_id: SessionId,
pub session_anchor_id: SessionAnchorId,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum BootstrapWaitOutcome {
Waiting,
Published,
Failed,
Refused,
}
#[derive(Debug)]
pub struct BootstrapWaiterFrameError(String);
impl fmt::Display for BootstrapWaiterFrameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for BootstrapWaiterFrameError {}
/// Writes one bounded bootstrap-waiter frame.
///
/// # Errors
/// Refuses oversized or unencodable records and stream write failures.
pub fn write_waiter_frame<T: Serialize>(
stream: &mut impl Write,
record: &T,
) -> Result<(), BootstrapWaiterFrameError> {
let encoded = codec::encode(record).map_err(failure)?;
if encoded.len() > MAX_WAITER_FRAME_BYTES {
return Err(failure("bootstrap waiter frame is oversized"));
}
let length = u32::try_from(encoded.len()).map_err(failure)?;
stream.write_all(&length.to_be_bytes()).map_err(failure)?;
stream.write_all(&encoded).map_err(failure)?;
stream.flush().map_err(failure)
}
/// Reads one bounded bootstrap-waiter frame.
///
/// # Errors
/// Refuses oversized, malformed, or incomplete frames and stream read failures.
pub fn read_waiter_frame<T>(stream: &mut impl Read) -> Result<T, BootstrapWaiterFrameError>
where
T: serde::de::DeserializeOwned + Serialize,
{
let mut length = [0_u8; 4];
stream.read_exact(&mut length).map_err(failure)?;
let length = u32::from_be_bytes(length) as usize;
if length > MAX_WAITER_FRAME_BYTES {
return Err(failure("bootstrap waiter frame is oversized"));
}
let mut encoded = vec![0_u8; length];
stream.read_exact(&mut encoded).map_err(failure)?;
codec::decode(&encoded).map_err(failure)
}
fn failure(error: impl fmt::Display) -> BootstrapWaiterFrameError {
BootstrapWaiterFrameError(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{ConfigurationFingerprint, DomainId};
#[test]
fn waiter_frame_round_trips_and_rejects_oversize_length() {
let outcome = BootstrapWaitOutcome::Published;
let mut bytes = Vec::new();
write_waiter_frame(&mut bytes, &outcome).unwrap();
assert_eq!(
read_waiter_frame::<BootstrapWaitOutcome>(&mut bytes.as_slice()).unwrap(),
outcome
);
let oversized_length = u32::try_from(MAX_WAITER_FRAME_BYTES + 1)
.unwrap()
.to_be_bytes();
let mut oversized = oversized_length.as_slice();
assert!(read_waiter_frame::<BootstrapWaitOutcome>(&mut oversized).is_err());
}
#[test]
fn waiter_request_contract_keeps_identity_fields() {
let request = BootstrapWaitRequest {
protocol_version: ProtocolVersion::V1,
domain_id: DomainId::new([1; 32]),
configuration_fingerprint: ConfigurationFingerprint::new([2; 32]),
cli_id: ClientId::new([3; 32]),
session_id: SessionId::new([4; 32]),
session_anchor_id: SessionAnchorId::new([5; 32]),
};
let mut bytes = Vec::new();
write_waiter_frame(&mut bytes, &request).unwrap();
assert_eq!(
read_waiter_frame::<BootstrapWaitRequest>(&mut bytes.as_slice()).unwrap(),
request
);
}
}

View file

@ -9,6 +9,12 @@ use super::{
SessionAnchorId, SessionId, SourceIdentity, fmt,
};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum StateResetScopeV1 {
All,
Selected(Vec<u8>),
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct StoreMetadata {
schema_id: String,

View file

@ -0,0 +1,20 @@
[package]
name = "lazaret-domain-state"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" }
redb.workspace = true
serde.workspace = true
[dev-dependencies]
rustix.workspace = true
tempfile = "=3.27.0"
[lints]
workspace = true

View file

@ -0,0 +1,895 @@
use std::fmt;
use std::fs::File;
use std::io;
use std::path::Path;
use lazaret_core::codec::{CodecError, decode, encode};
use lazaret_core::identity::{GenerationId, SessionId};
pub use lazaret_core::records::StateResetScopeV1;
use lazaret_core::records::{
Domain, DomainStore, DomainStoreMetadata, DurableDecision, Generation, Mailbox, Participant,
ParticipantRole, ParticipantState, RecordTransitionError, Session, SessionPhase,
SessionTerminal, SessionTerminalState,
};
use lazaret_linux::filesystem::ExclusiveFileLock;
use redb::{
CommitError, Database, DatabaseError, ReadableDatabase, ReadableTable, StorageError,
TableDefinition, TableError, TransactionError,
};
use serde::{Deserialize, Serialize};
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("metadata");
const DOMAINS: TableDefinition<u8, &[u8]> = TableDefinition::new("domains");
const GENERATIONS: TableDefinition<u64, &[u8]> = TableDefinition::new("generations");
const PARTICIPANTS: TableDefinition<&[u8; 9], &[u8]> = TableDefinition::new("participants");
const SESSIONS: TableDefinition<&[u8; 40], &[u8]> = TableDefinition::new("sessions");
const SESSION_TERMINALS: TableDefinition<&[u8; 40], &[u8]> =
TableDefinition::new("session_terminals");
const MAILBOXES: TableDefinition<u64, &[u8]> = TableDefinition::new("mailboxes");
const STATE_RESET: TableDefinition<u8, &[u8]> = TableDefinition::new("state_reset");
const SINGLETON_KEY: u8 = 0;
const STATE_RESET_SCHEMA_V1: &str = "lazaret-state-reset-v1";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct StateResetIntentV1 {
schema: String,
domain_id: lazaret_core::identity::DomainId,
scope: StateResetScopeV1,
}
impl StateResetIntentV1 {
#[must_use]
pub fn new(domain_id: lazaret_core::identity::DomainId, scope: StateResetScopeV1) -> Self {
Self {
schema: STATE_RESET_SCHEMA_V1.to_owned(),
domain_id,
scope,
}
}
#[must_use]
pub const fn domain_id(&self) -> lazaret_core::identity::DomainId {
self.domain_id
}
#[must_use]
pub const fn scope(&self) -> &StateResetScopeV1 {
&self.scope
}
fn validate(&self) -> Result<(), DomainStoreError> {
if self.schema == STATE_RESET_SCHEMA_V1 {
Ok(())
} else {
Err(DomainStoreError::InvalidRow("State Reset"))
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DomainStoreMutation {
PutGeneration(Generation),
PutParticipant(Participant),
CleanParticipantAfterAbsence(Participant),
PutSession(Session),
PutMailbox(Mailbox),
CleanMailboxAfterAbsence(Mailbox),
PutSessionTerminal(SessionTerminal),
CleanSessionTerminalAfterAbsence(SessionTerminal),
}
#[derive(Debug)]
pub enum DomainStoreError {
Io(io::Error),
Database(DatabaseError),
Transaction(TransactionError),
Table(TableError),
Storage(StorageError),
Commit(CommitError),
Codec(CodecError),
Transition(RecordTransitionError),
MissingMetadata,
MissingRow(&'static str),
DuplicateRow(&'static str),
InvalidRow(&'static str),
DomainMismatch,
GenerationMismatch,
}
impl fmt::Display for DomainStoreError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "Domain Store I/O failed: {error}"),
Self::Database(error) => write!(formatter, "Domain Store open failed: {error}"),
Self::Transaction(error) => {
write!(formatter, "Domain Store transaction failed: {error}")
}
Self::Table(error) => write!(formatter, "Domain Store table failed: {error}"),
Self::Storage(error) => write!(formatter, "Domain Store storage failed: {error}"),
Self::Commit(error) => write!(formatter, "Domain Store commit failed: {error}"),
Self::Codec(error) => {
write!(formatter, "Domain Store record failed validation: {error}")
}
Self::Transition(error) => {
write!(formatter, "Domain Store transition refused: {error}")
}
Self::MissingMetadata => formatter.write_str("Domain Store metadata is missing"),
Self::MissingRow(kind) => write!(formatter, "referenced {kind} row is missing"),
Self::DuplicateRow(kind) => write!(formatter, "duplicate {kind} row"),
Self::InvalidRow(kind) => write!(formatter, "invalid {kind} row"),
Self::DomainMismatch => formatter.write_str("Domain Store Domain identity mismatch"),
Self::GenerationMismatch => formatter.write_str("Domain Store generation mismatch"),
}
}
}
impl std::error::Error for DomainStoreError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
Self::Database(error) => Some(error),
Self::Transaction(error) => Some(error),
Self::Table(error) => Some(error),
Self::Storage(error) => Some(error),
Self::Commit(error) => Some(error),
Self::Codec(error) => Some(error),
Self::Transition(error) => Some(error),
Self::MissingMetadata
| Self::MissingRow(_)
| Self::DuplicateRow(_)
| Self::InvalidRow(_)
| Self::DomainMismatch
| Self::GenerationMismatch => None,
}
}
}
macro_rules! from_error {
($source:ty, $variant:ident) => {
impl From<$source> for DomainStoreError {
fn from(error: $source) -> Self {
Self::$variant(error)
}
}
};
}
from_error!(io::Error, Io);
from_error!(DatabaseError, Database);
from_error!(TransactionError, Transaction);
from_error!(TableError, Table);
from_error!(StorageError, Storage);
from_error!(CommitError, Commit);
from_error!(CodecError, Codec);
from_error!(RecordTransitionError, Transition);
pub struct HostCoordinatorDomainStore {
database: Database,
_domain_lock: ExclusiveFileLock,
}
impl HostCoordinatorDomainStore {
/// Opens the Domain Store while retaining the caller's exclusive Domain lock.
///
/// # Errors
///
/// Refuses I/O, database, metadata, row, or Domain identity failures.
pub fn open(
domain_lock: ExclusiveFileLock,
database_path: &Path,
expected_domain: &Domain,
) -> Result<Self, DomainStoreError> {
let is_new = !database_path.try_exists()?;
let database = if is_new {
Database::create(database_path)?
} else {
Database::open(database_path)?
};
let store = Self {
database,
_domain_lock: domain_lock,
};
if is_new {
store.initialize(expected_domain)?;
} else {
store.validate_metadata()?;
if store.snapshot()?.domain != *expected_domain {
return Err(DomainStoreError::DomainMismatch);
}
}
Ok(store)
}
/// Opens the Domain Store from a descriptor-relative file while retaining the Domain lock.
///
/// # Errors
/// Refuses I/O, database, metadata, row, or Domain identity failures.
pub fn open_file(
domain_lock: ExclusiveFileLock,
database_file: File,
expected_domain: &Domain,
) -> Result<Self, DomainStoreError> {
let is_new = database_file.metadata()?.len() == 0;
let database = redb::Database::builder().create_file(database_file)?;
let store = Self {
database,
_domain_lock: domain_lock,
};
if is_new {
store.initialize(expected_domain)?;
} else {
store.validate_metadata()?;
if store.snapshot()?.domain != *expected_domain {
return Err(DomainStoreError::DomainMismatch);
}
}
Ok(store)
}
/// Reads and validates every typed row as one consistent redb snapshot.
///
/// # Errors
///
/// Refuses database, table, codec, key, duplicate, or relationship failures.
pub fn snapshot(&self) -> Result<DomainStore, DomainStoreError> {
self.validate_metadata()?;
let read = self.database.begin_read()?;
let domain = read_domain(&read.open_table(DOMAINS)?)?;
let generations = read_generations(&read.open_table(GENERATIONS)?)?;
let participants = read_participants(&read.open_table(PARTICIPANTS)?)?;
let sessions = read_sessions(&read.open_table(SESSIONS)?)?;
let session_terminals = read_session_terminals(&read.open_table(SESSION_TERMINALS)?)?;
let mailboxes = read_mailboxes(&read.open_table(MAILBOXES)?)?;
let snapshot = DomainStore {
metadata: DomainStoreMetadata::v1(),
domain,
generations,
participants,
sessions,
session_terminals,
mailboxes,
};
validate_snapshot(&snapshot)?;
Ok(snapshot)
}
/// Applies the complete mutation batch in one immediate-durability redb transaction.
///
/// # Errors
///
/// Refuses invalid transitions, generation mismatches, malformed relationships, or storage
/// failures without committing any member of the batch.
pub fn apply(&self, mutations: &[DomainStoreMutation]) -> Result<(), DomainStoreError> {
if mutations.is_empty() {
return Ok(());
}
let mut snapshot = self.snapshot()?;
for mutation in mutations {
apply_mutation(&mut snapshot, mutation)?;
}
validate_snapshot(&snapshot)?;
self.write_snapshot(&snapshot)
}
/// Reads the typed reset recovery point from the versioned Domain Store.
///
/// # Errors
/// Refuses malformed, unknown-version, or mismatched-Domain intent.
pub fn state_reset_intent(&self) -> Result<Option<StateResetIntentV1>, DomainStoreError> {
self.validate_metadata()?;
let read = self.database.begin_read()?;
let table = match read.open_table(STATE_RESET) {
Ok(table) => table,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(error) => return Err(error.into()),
};
let Some(value) = table.get(SINGLETON_KEY)? else {
return Ok(None);
};
let intent = decode::<StateResetIntentV1>(value.value())?;
intent.validate()?;
if intent.domain_id != self.snapshot()?.domain.domain_id {
return Err(DomainStoreError::DomainMismatch);
}
Ok(Some(intent))
}
/// Commits the reset recovery point in one immediate-durability transaction.
///
/// # Errors
/// Refuses an invalid, foreign, or already-present intent.
pub fn begin_state_reset(&self, intent: &StateResetIntentV1) -> Result<(), DomainStoreError> {
intent.validate()?;
if intent.domain_id != self.snapshot()?.domain.domain_id {
return Err(DomainStoreError::DomainMismatch);
}
let write = self.database.begin_write()?;
{
let mut table = write.open_table(STATE_RESET)?;
if table.get(SINGLETON_KEY)?.is_some() {
return Err(DomainStoreError::DuplicateRow("State Reset"));
}
let encoded = encode(intent)?;
table.insert(SINGLETON_KEY, encoded.as_slice())?;
}
write.commit()?;
Ok(())
}
/// Removes the exact completed intent in one immediate-durability transaction.
///
/// # Errors
/// Refuses missing or changed intent and storage failures.
pub fn finish_state_reset(
&self,
expected: &StateResetIntentV1,
) -> Result<(), DomainStoreError> {
if self.state_reset_intent()?.as_ref() != Some(expected) {
return Err(DomainStoreError::InvalidRow("State Reset"));
}
let write = self.database.begin_write()?;
{
let mut table = write.open_table(STATE_RESET)?;
table.remove(SINGLETON_KEY)?;
}
write.commit()?;
Ok(())
}
fn initialize(&self, domain: &Domain) -> Result<(), DomainStoreError> {
let snapshot = DomainStore {
metadata: DomainStoreMetadata::v1(),
domain: domain.clone(),
generations: Vec::new(),
participants: Vec::new(),
sessions: Vec::new(),
session_terminals: Vec::new(),
mailboxes: Vec::new(),
};
self.write_snapshot(&snapshot)
}
fn validate_metadata(&self) -> Result<(), DomainStoreError> {
let read = self.database.begin_read()?;
let table = read.open_table(METADATA).map_err(|error| match error {
TableError::TableDoesNotExist(_) => DomainStoreError::MissingMetadata,
other => DomainStoreError::Table(other),
})?;
read_metadata(&table)?;
Ok(())
}
fn write_snapshot(&self, snapshot: &DomainStore) -> Result<(), DomainStoreError> {
let write = self.database.begin_write()?;
{
let mut table = write.open_table(METADATA)?;
let encoded = encode(&snapshot.metadata)?;
table.insert(SINGLETON_KEY, encoded.as_slice())?;
}
{
let mut table = write.open_table(DOMAINS)?;
let encoded = encode(&snapshot.domain)?;
table.insert(SINGLETON_KEY, encoded.as_slice())?;
}
{
let mut table = write.open_table(GENERATIONS)?;
for record in &snapshot.generations {
let encoded = encode(record)?;
table.insert(record.generation_id.get(), encoded.as_slice())?;
}
}
{
let mut table = write.open_table(PARTICIPANTS)?;
for record in &snapshot.participants {
let encoded = encode(record)?;
table.insert(&participant_key(record), encoded.as_slice())?;
}
}
{
let mut table = write.open_table(SESSIONS)?;
for record in &snapshot.sessions {
let encoded = encode(record)?;
table.insert(
&session_key(record.generation_id, record.session_id),
encoded.as_slice(),
)?;
}
}
{
let mut table = write.open_table(SESSION_TERMINALS)?;
for record in &snapshot.session_terminals {
let encoded = encode(record)?;
table.insert(
&session_key(record.generation_id(), record.session_id()),
encoded.as_slice(),
)?;
}
}
{
let mut table = write.open_table(MAILBOXES)?;
for record in &snapshot.mailboxes {
let encoded = encode(record)?;
table.insert(record.generation_id.get(), encoded.as_slice())?;
}
}
write.commit()?;
Ok(())
}
}
fn read_generations(
table: &redb::ReadOnlyTable<u64, &[u8]>,
) -> Result<Vec<Generation>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: Generation = decode(value.value())?;
if key.value() != record.generation_id.get() {
return Err(DomainStoreError::InvalidRow("Generation key"));
}
records.push(record);
}
Ok(records)
}
fn read_domain(table: &redb::ReadOnlyTable<u8, &[u8]>) -> Result<Domain, DomainStoreError> {
let mut domain = None;
for entry in table.iter()? {
let (key, value) = entry?;
let record: Domain = decode(value.value())?;
if key.value() != SINGLETON_KEY {
return Err(DomainStoreError::InvalidRow("Domain key"));
}
if domain.replace(record).is_some() {
return Err(DomainStoreError::DuplicateRow("Domain"));
}
}
domain.ok_or(DomainStoreError::MissingRow("Domain"))
}
fn read_metadata(
table: &redb::ReadOnlyTable<u8, &[u8]>,
) -> Result<DomainStoreMetadata, DomainStoreError> {
let mut metadata = None;
for entry in table.iter()? {
let (key, value) = entry?;
let record: DomainStoreMetadata = decode(value.value())?;
if key.value() != SINGLETON_KEY {
return Err(DomainStoreError::InvalidRow("StoreMetadata key"));
}
if metadata.replace(record).is_some() {
return Err(DomainStoreError::DuplicateRow("StoreMetadata"));
}
}
metadata.ok_or(DomainStoreError::MissingMetadata)
}
fn read_participants(
table: &redb::ReadOnlyTable<&[u8; 9], &[u8]>,
) -> Result<Vec<Participant>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: Participant = decode(value.value())?;
if key.value() != &participant_key(&record) {
return Err(DomainStoreError::InvalidRow("Participant key"));
}
records.push(record);
}
Ok(records)
}
fn read_sessions(
table: &redb::ReadOnlyTable<&[u8; 40], &[u8]>,
) -> Result<Vec<Session>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: Session = decode(value.value())?;
if key.value() != &session_key(record.generation_id, record.session_id) {
return Err(DomainStoreError::InvalidRow("Session key"));
}
records.push(record);
}
Ok(records)
}
fn read_session_terminals(
table: &redb::ReadOnlyTable<&[u8; 40], &[u8]>,
) -> Result<Vec<SessionTerminal>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: SessionTerminal = decode(value.value())?;
if key.value() != &session_key(record.generation_id(), record.session_id()) {
return Err(DomainStoreError::InvalidRow("Session Terminal key"));
}
records.push(record);
}
Ok(records)
}
fn read_mailboxes(
table: &redb::ReadOnlyTable<u64, &[u8]>,
) -> Result<Vec<Mailbox>, DomainStoreError> {
let mut records = Vec::new();
for entry in table.iter()? {
let (key, value) = entry?;
let record: Mailbox = decode(value.value())?;
if key.value() != record.generation_id.get() {
return Err(DomainStoreError::InvalidRow("Mailbox key"));
}
records.push(record);
}
Ok(records)
}
fn apply_mutation(
snapshot: &mut DomainStore,
mutation: &DomainStoreMutation,
) -> Result<(), DomainStoreError> {
match mutation {
DomainStoreMutation::PutGeneration(next) => put_generation(snapshot, next.clone()),
DomainStoreMutation::PutParticipant(next) => put_participant(snapshot, next.clone(), false),
DomainStoreMutation::CleanParticipantAfterAbsence(next) => {
put_participant(snapshot, next.clone(), true)
}
DomainStoreMutation::PutSession(next) => put_session(snapshot, next.clone()),
DomainStoreMutation::PutMailbox(next) => put_mailbox(snapshot, next.clone(), false),
DomainStoreMutation::CleanMailboxAfterAbsence(next) => {
put_mailbox(snapshot, next.clone(), true)
}
DomainStoreMutation::PutSessionTerminal(next) => {
put_session_terminal(snapshot, next.clone(), false)
}
DomainStoreMutation::CleanSessionTerminalAfterAbsence(next) => {
put_session_terminal(snapshot, next.clone(), true)
}
}
}
fn put_generation(snapshot: &mut DomainStore, next: Generation) -> Result<(), DomainStoreError> {
if next.domain_id != snapshot.domain.domain_id {
return Err(DomainStoreError::DomainMismatch);
}
if let Some(current) = snapshot
.generations
.iter_mut()
.find(|record| record.generation_id == next.generation_id)
{
validate_generation_transition(current, &next)?;
*current = next;
} else {
if next.durable_decision != DurableDecision::None {
return Err(DomainStoreError::InvalidRow("initial Generation"));
}
snapshot.generations.push(next);
}
Ok(())
}
fn put_participant(
snapshot: &mut DomainStore,
next: Participant,
absence_proven: bool,
) -> Result<(), DomainStoreError> {
require_generation(snapshot, next.generation_id())?;
if let Some(current) = snapshot.participants.iter_mut().find(|record| {
record.generation_id() == next.generation_id() && record.role() == next.role()
}) {
if absence_proven {
current.validate_transition_to_clean_after_absence(&next)?;
} else {
current.validate_transition_to(&next)?;
}
*current = next;
} else {
if absence_proven || next.state() != ParticipantState::Intent || next.ownership().is_some()
{
return Err(DomainStoreError::InvalidRow("initial Participant"));
}
snapshot.participants.push(next);
}
Ok(())
}
fn put_session(snapshot: &mut DomainStore, next: Session) -> Result<(), DomainStoreError> {
require_generation(snapshot, next.generation_id)?;
if let Some(current) = snapshot.sessions.iter_mut().find(|record| {
record.generation_id == next.generation_id && record.session_id == next.session_id
}) {
validate_session_transition(current, &next)?;
*current = next;
} else {
if !matches!(
next.session_phase,
SessionPhase::New | SessionPhase::Waiting | SessionPhase::Registering
) {
return Err(DomainStoreError::InvalidRow("initial Session"));
}
snapshot.sessions.push(next);
}
Ok(())
}
fn put_mailbox(
snapshot: &mut DomainStore,
next: Mailbox,
absence_proven: bool,
) -> Result<(), DomainStoreError> {
require_generation(snapshot, next.generation_id)?;
if let Some(current) = snapshot
.mailboxes
.iter_mut()
.find(|record| record.generation_id == next.generation_id)
{
validate_mailbox_transition(current, &next, absence_proven)?;
*current = next;
} else {
if absence_proven || next.state != ParticipantState::Intent {
return Err(DomainStoreError::InvalidRow("initial Mailbox"));
}
snapshot.mailboxes.push(next);
}
Ok(())
}
fn put_session_terminal(
snapshot: &mut DomainStore,
next: SessionTerminal,
absence_proven: bool,
) -> Result<(), DomainStoreError> {
require_session(snapshot, next.generation_id(), next.session_id())?;
if let Some(current) = snapshot.session_terminals.iter_mut().find(|record| {
record.generation_id() == next.generation_id() && record.session_id() == next.session_id()
}) {
if absence_proven {
current.validate_transition_to_clean_after_absence(&next)?;
} else {
current.validate_transition_to(&next)?;
}
*current = next;
} else {
if absence_proven
|| next.state() != SessionTerminalState::Intent
|| next.container_id().is_some()
|| !next.host_coordinator().is_unassigned()
|| next.pty_master().is_some()
|| !next.exec_client().is_unassigned()
|| next.guest_process().is_some()
{
return Err(DomainStoreError::InvalidRow("initial Session Terminal"));
}
snapshot.session_terminals.push(next);
}
Ok(())
}
fn validate_generation_transition(
current: &Generation,
next: &Generation,
) -> Result<(), DomainStoreError> {
if current.domain_id != next.domain_id
|| current.generation_id != next.generation_id
|| current.configuration_fingerprint != next.configuration_fingerprint
|| current.coordinator_id != next.coordinator_id
|| current.network_sidecar_id != next.network_sidecar_id
|| current.sandbox_id != next.sandbox_id
{
return Err(DomainStoreError::InvalidRow("Generation identity"));
}
let valid = current.durable_decision == next.durable_decision
|| matches!(
(current.durable_decision, next.durable_decision),
(
DurableDecision::None,
DurableDecision::Preparing | DurableDecision::Clean
) | (
DurableDecision::Preparing,
DurableDecision::Published | DurableDecision::Clean
) | (DurableDecision::Published, DurableDecision::Clean)
);
if valid {
Ok(())
} else {
Err(DomainStoreError::InvalidRow("Generation transition"))
}
}
fn validate_session_transition(current: &Session, next: &Session) -> Result<(), DomainStoreError> {
if current.generation_id != next.generation_id
|| current.session_id != next.session_id
|| current.cli_id != next.cli_id
|| current.session_anchor_id != next.session_anchor_id
{
return Err(DomainStoreError::InvalidRow("Session identity"));
}
let valid = current.session_phase == next.session_phase
|| matches!(
(current.session_phase, next.session_phase),
(
SessionPhase::New,
SessionPhase::Waiting | SessionPhase::Registering | SessionPhase::Failed
) | (
SessionPhase::Waiting,
SessionPhase::Registering | SessionPhase::Closed | SessionPhase::Failed
) | (
SessionPhase::Registering | SessionPhase::HostExecuting,
SessionPhase::Attached | SessionPhase::Closed | SessionPhase::Failed
) | (
SessionPhase::Attached,
SessionPhase::Prompting
| SessionPhase::HostExecuting
| SessionPhase::Closed
| SessionPhase::Failed
) | (
SessionPhase::Prompting,
SessionPhase::Attached
| SessionPhase::HostExecuting
| SessionPhase::Closed
| SessionPhase::Failed
)
);
if valid {
Ok(())
} else {
Err(DomainStoreError::InvalidRow("Session transition"))
}
}
fn validate_mailbox_transition(
current: &Mailbox,
next: &Mailbox,
absence_proven: bool,
) -> Result<(), DomainStoreError> {
if current.generation_id != next.generation_id
|| current.identity_path != next.identity_path
|| current.inbox_path != next.inbox_path
|| current.outbox_path != next.outbox_path
{
return Err(DomainStoreError::InvalidRow("Mailbox identity"));
}
if absence_proven {
return if matches!(
current.state,
ParticipantState::Intent | ParticipantState::ReleaseIntent
) && next.state == ParticipantState::Clean
{
Ok(())
} else {
Err(DomainStoreError::InvalidRow("Mailbox cleanup"))
};
}
let valid = current.state == next.state
|| matches!(
(current.state, next.state),
(
ParticipantState::Intent,
ParticipantState::LeaseHeld
| ParticipantState::Prepared
| ParticipantState::Committed
| ParticipantState::ReleaseIntent
) | (
ParticipantState::LeaseHeld
| ParticipantState::Prepared
| ParticipantState::Committed,
ParticipantState::ReleaseIntent
)
);
if valid {
Ok(())
} else {
Err(DomainStoreError::InvalidRow("Mailbox transition"))
}
}
fn validate_snapshot(snapshot: &DomainStore) -> Result<(), DomainStoreError> {
for (index, generation) in snapshot.generations.iter().enumerate() {
if generation.domain_id != snapshot.domain.domain_id {
return Err(DomainStoreError::DomainMismatch);
}
if snapshot.generations[..index]
.iter()
.any(|other| other.generation_id == generation.generation_id)
{
return Err(DomainStoreError::DuplicateRow("Generation"));
}
}
for (index, participant) in snapshot.participants.iter().enumerate() {
require_generation(snapshot, participant.generation_id())?;
if snapshot.participants[..index].iter().any(|other| {
other.generation_id() == participant.generation_id()
&& other.role() == participant.role()
}) {
return Err(DomainStoreError::DuplicateRow("Participant"));
}
}
for (index, session) in snapshot.sessions.iter().enumerate() {
require_generation(snapshot, session.generation_id)?;
if snapshot.sessions[..index].iter().any(|other| {
other.generation_id == session.generation_id && other.session_id == session.session_id
}) {
return Err(DomainStoreError::DuplicateRow("Session"));
}
}
for (index, terminal) in snapshot.session_terminals.iter().enumerate() {
require_session(snapshot, terminal.generation_id(), terminal.session_id())?;
if snapshot.session_terminals[..index].iter().any(|other| {
other.generation_id() == terminal.generation_id()
&& other.session_id() == terminal.session_id()
}) {
return Err(DomainStoreError::DuplicateRow("Session Terminal"));
}
}
for (index, mailbox) in snapshot.mailboxes.iter().enumerate() {
require_generation(snapshot, mailbox.generation_id)?;
if snapshot.mailboxes[..index]
.iter()
.any(|other| other.generation_id == mailbox.generation_id)
{
return Err(DomainStoreError::DuplicateRow("Mailbox"));
}
}
Ok(())
}
fn require_generation(
snapshot: &DomainStore,
generation_id: GenerationId,
) -> Result<(), DomainStoreError> {
if snapshot
.generations
.iter()
.any(|record| record.generation_id == generation_id)
{
Ok(())
} else {
Err(DomainStoreError::GenerationMismatch)
}
}
fn require_session(
snapshot: &DomainStore,
generation_id: GenerationId,
session_id: SessionId,
) -> Result<(), DomainStoreError> {
require_generation(snapshot, generation_id)?;
if snapshot
.sessions
.iter()
.any(|record| record.generation_id == generation_id && record.session_id == session_id)
{
Ok(())
} else {
Err(DomainStoreError::MissingRow("Session"))
}
}
fn participant_key(record: &Participant) -> [u8; 9] {
let mut key = [0; 9];
key[..8].copy_from_slice(&record.generation_id().get().to_be_bytes());
key[8] = role_key(record.role());
key
}
fn role_key(role: ParticipantRole) -> u8 {
match role {
ParticipantRole::PreparedLower => 0,
ParticipantRole::StateAnchor => 1,
ParticipantRole::GitRouting => 2,
ParticipantRole::Network => 3,
ParticipantRole::Helper => 4,
ParticipantRole::Sandbox => 5,
ParticipantRole::Publication => 6,
}
}
fn session_key(generation_id: GenerationId, session_id: SessionId) -> [u8; 40] {
let mut key = [0; 40];
key[..8].copy_from_slice(&generation_id.get().to_be_bytes());
key[8..].copy_from_slice(session_id.as_bytes());
key
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,494 @@
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use lazaret_core::codec::encode;
use lazaret_core::identity::{
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, HostPath,
NetworkSidecarId, SandboxId, SessionAnchorId, SessionId,
};
use lazaret_core::records::{
DurableDecision, ParticipantOwnership, PersistedHostProcessOwnership, PublicationOwnership,
SessionTerminalFields,
};
use lazaret_linux::filesystem::try_exclusive_flock;
use redb::{ReadableDatabase, TableDefinition, TableHandle};
use serde::Serialize;
use super::*;
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
const FOREIGN: TableDefinition<u8, u8> = TableDefinition::new("foreign");
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new(label: &str) -> Self {
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"lazaret-domain-store-{label}-{}-{sequence}",
std::process::id()
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn bytes(value: u8) -> [u8; 32] {
[value; 32]
}
fn domain() -> Domain {
Domain {
domain_id: DomainId::new(bytes(1)),
profile_name: "trusted".into(),
canonical_workspace: HostPath::new(b"/workspace/project".to_vec()),
}
}
fn generation(decision: DurableDecision) -> Generation {
Generation {
domain_id: domain().domain_id,
generation_id: GenerationId::new(7),
configuration_fingerprint: ConfigurationFingerprint::new(bytes(2)),
coordinator_id: CoordinatorId::new(bytes(3)),
network_sidecar_id: NetworkSidecarId::new(bytes(4)),
sandbox_id: SandboxId::new(bytes(5)),
durable_decision: decision,
}
}
fn session(phase: SessionPhase) -> Session {
Session {
generation_id: GenerationId::new(7),
session_id: SessionId::new(bytes(6)),
cli_id: ClientId::new(bytes(7)),
session_anchor_id: SessionAnchorId::new(bytes(8)),
session_phase: phase,
}
}
fn mailbox(state: ParticipantState) -> Mailbox {
Mailbox {
generation_id: GenerationId::new(7),
identity_path: HostPath::new(b"identity".to_vec()),
inbox_path: HostPath::new(b"inbox".to_vec()),
outbox_path: HostPath::new(b"outbox".to_vec()),
state,
}
}
fn bare_terminal(state: SessionTerminalState) -> SessionTerminal {
SessionTerminal::snapshot(SessionTerminalFields {
generation_id: GenerationId::new(7),
session_id: SessionId::new(bytes(6)),
state,
container_id: None,
host_coordinator: PersistedHostProcessOwnership::unassigned(),
pty_master: None,
exec_client: PersistedHostProcessOwnership::unassigned(),
guest_process: None,
})
.unwrap()
}
fn publication_intent() -> Participant {
Participant::bare_intent(GenerationId::new(7), ParticipantRole::Publication)
}
fn publication_partial() -> Participant {
Participant::snapshot(
GenerationId::new(7),
ParticipantRole::Publication,
ParticipantState::Intent,
Some(ParticipantOwnership::Publication(PublicationOwnership {
staged_socket_path: None,
staged_identity_path: None,
socket_path: Some(HostPath::new(b"control.sock".to_vec())),
identity_path: None,
socket_identity: None,
identity_file_identity: None,
})),
)
.unwrap()
}
fn lock_file(path: &Path) -> File {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.unwrap()
}
fn open_store(directory: &TestDirectory) -> HostCoordinatorDomainStore {
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()).unwrap()
}
#[test]
fn reset_intent_is_typed_transactional_and_domain_bound() {
let directory = TestDirectory::new("state-reset");
let store = open_store(&directory);
let intent = StateResetIntentV1::new(
domain().domain_id,
StateResetScopeV1::Selected(b"/usr".to_vec()),
);
assert_eq!(store.state_reset_intent().unwrap(), None);
store.begin_state_reset(&intent).unwrap();
assert_eq!(store.state_reset_intent().unwrap(), Some(intent.clone()));
assert!(store.begin_state_reset(&intent).is_err());
store.finish_state_reset(&intent).unwrap();
assert_eq!(store.state_reset_intent().unwrap(), None);
let foreign = StateResetIntentV1::new(DomainId::new(bytes(99)), StateResetScopeV1::All);
assert!(matches!(
store.begin_state_reset(&foreign),
Err(DomainStoreError::DomainMismatch)
));
}
#[test]
fn reset_intent_unknown_version_refuses_before_scope_use() {
let directory = TestDirectory::new("state-reset-version");
let store = open_store(&directory);
let intent = StateResetIntentV1 {
schema: "lazaret-state-reset-v2".into(),
domain_id: domain().domain_id,
scope: StateResetScopeV1::All,
};
assert!(matches!(
store.begin_state_reset(&intent),
Err(DomainStoreError::InvalidRow("State Reset"))
));
}
#[test]
fn malformed_persisted_reset_intent_refuses_without_fallback() {
let directory = TestDirectory::new("state-reset-malformed");
drop(open_store(&directory));
let database = Database::open(directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(STATE_RESET).unwrap();
table
.insert(SINGLETON_KEY, b"not-postcard".as_slice())
.unwrap();
}
write.commit().unwrap();
drop(database);
assert!(matches!(
open_store(&directory).state_reset_intent(),
Err(DomainStoreError::Codec(_))
));
}
#[test]
fn descriptor_store_open_cannot_be_redirected_by_path_replacement() {
let directory = TestDirectory::new("descriptor-open");
let database_path = directory.path("domain.redb");
let database_file = lock_file(&database_path);
let retained_path = directory.path("retained.redb");
std::fs::rename(&database_path, &retained_path).unwrap();
drop(lock_file(&database_path));
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
drop(HostCoordinatorDomainStore::open_file(lock, database_file, &domain()).unwrap());
assert!(Database::open(retained_path).is_ok());
assert_eq!(std::fs::metadata(database_path).unwrap().len(), 0);
}
#[test]
fn transaction_reopen_and_interrupted_write_preserve_complete_documents() {
let directory = TestDirectory::new("transaction");
let store = open_store(&directory);
store
.apply(&[
DomainStoreMutation::PutGeneration(generation(DurableDecision::None)),
DomainStoreMutation::PutParticipant(publication_intent()),
DomainStoreMutation::PutSession(session(SessionPhase::New)),
DomainStoreMutation::PutMailbox(mailbox(ParticipantState::Intent)),
DomainStoreMutation::PutSessionTerminal(bare_terminal(SessionTerminalState::Intent)),
])
.unwrap();
let refused = store.apply(&[
DomainStoreMutation::PutGeneration(generation(DurableDecision::Preparing)),
DomainStoreMutation::PutSession(session(SessionPhase::Attached)),
]);
assert!(matches!(
refused,
Err(DomainStoreError::InvalidRow("Session transition"))
));
assert_eq!(
store.snapshot().unwrap().generations[0].durable_decision,
DurableDecision::None
);
drop(store);
let database = Database::open(directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(GENERATIONS).unwrap();
let encoded = encode(&generation(DurableDecision::Preparing)).unwrap();
table.insert(7, encoded.as_slice()).unwrap();
}
drop(write);
drop(database);
let reopened = open_store(&directory);
let snapshot = reopened.snapshot().unwrap();
assert_eq!(
snapshot.generations[0].durable_decision,
DurableDecision::None
);
assert_eq!(snapshot.participants.len(), 1);
assert_eq!(snapshot.sessions.len(), 1);
assert_eq!(snapshot.mailboxes.len(), 1);
assert_eq!(snapshot.session_terminals.len(), 1);
}
#[test]
fn opener_retains_the_domain_lock_for_its_complete_lifetime() {
let directory = TestDirectory::new("opener");
let store = open_store(&directory);
assert!(try_exclusive_flock(lock_file(&directory.path("domain.lock"))).is_err());
assert!(matches!(
Database::open(directory.path("domain.redb")),
Err(DatabaseError::DatabaseAlreadyOpen)
));
drop(store);
assert!(try_exclusive_flock(lock_file(&directory.path("domain.lock"))).is_ok());
}
#[test]
fn metadata_and_typed_row_validation_fail_closed() {
#[derive(Serialize)]
struct RawMetadata<'a> {
schema_id: &'a str,
schema_version: u32,
}
let directory = TestDirectory::new("metadata");
drop(open_store(&directory));
let database = Database::open(directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(METADATA).unwrap();
let encoded = encode(&RawMetadata {
schema_id: "lazaret-domain-store-v1",
schema_version: 2,
})
.unwrap();
table.insert(SINGLETON_KEY, encoded.as_slice()).unwrap();
}
write.commit().unwrap();
drop(database);
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
assert!(matches!(
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()),
Err(DomainStoreError::Codec(_))
));
let extra_metadata_directory = TestDirectory::new("extra-metadata-row");
drop(open_store(&extra_metadata_directory));
let database = Database::open(extra_metadata_directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(METADATA).unwrap();
table.insert(1, &[0xff, 0xff][..]).unwrap();
}
write.commit().unwrap();
drop(database);
let lock =
try_exclusive_flock(lock_file(&extra_metadata_directory.path("domain.lock"))).unwrap();
assert!(matches!(
HostCoordinatorDomainStore::open(
lock,
&extra_metadata_directory.path("domain.redb"),
&domain(),
),
Err(DomainStoreError::Codec(_))
));
let row_directory = TestDirectory::new("row-key");
let store = open_store(&row_directory);
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
DurableDecision::None,
))])
.unwrap();
drop(store);
let database = Database::open(row_directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(GENERATIONS).unwrap();
let encoded = encode(&generation(DurableDecision::None)).unwrap();
table.insert(8, encoded.as_slice()).unwrap();
}
write.commit().unwrap();
drop(database);
let lock = try_exclusive_flock(lock_file(&row_directory.path("domain.lock"))).unwrap();
assert!(matches!(
HostCoordinatorDomainStore::open(lock, &row_directory.path("domain.redb"), &domain()),
Err(DomainStoreError::InvalidRow("Generation key"))
));
let domain_directory = TestDirectory::new("extra-domain-row");
drop(open_store(&domain_directory));
let database = Database::open(domain_directory.path("domain.redb")).unwrap();
let write = database.begin_write().unwrap();
{
let mut table = write.open_table(DOMAINS).unwrap();
table.insert(1, &[0xff, 0xff][..]).unwrap();
}
write.commit().unwrap();
drop(database);
let lock = try_exclusive_flock(lock_file(&domain_directory.path("domain.lock"))).unwrap();
assert!(matches!(
HostCoordinatorDomainStore::open(lock, &domain_directory.path("domain.redb"), &domain()),
Err(DomainStoreError::Codec(_))
));
}
#[test]
fn generation_relationships_and_domain_identity_cannot_be_forged() {
let directory = TestDirectory::new("relationships");
let store = open_store(&directory);
assert!(matches!(
store.apply(&[DomainStoreMutation::PutParticipant(publication_intent())]),
Err(DomainStoreError::GenerationMismatch)
));
let mut wrong_domain = generation(DurableDecision::None);
wrong_domain.domain_id = DomainId::new(bytes(99));
assert!(matches!(
store.apply(&[DomainStoreMutation::PutGeneration(wrong_domain)]),
Err(DomainStoreError::DomainMismatch)
));
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
DurableDecision::None,
))])
.unwrap();
drop(store);
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
let mut other_domain = domain();
other_domain.profile_name = "other".into();
assert!(matches!(
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &other_domain),
Err(DomainStoreError::DomainMismatch)
));
}
#[test]
fn lifecycle_updates_enforce_transition_and_absence_boundaries() {
let directory = TestDirectory::new("transitions");
let store = open_store(&directory);
store
.apply(&[
DomainStoreMutation::PutGeneration(generation(DurableDecision::None)),
DomainStoreMutation::PutParticipant(publication_intent()),
DomainStoreMutation::PutSession(session(SessionPhase::New)),
DomainStoreMutation::PutMailbox(mailbox(ParticipantState::Intent)),
DomainStoreMutation::PutSessionTerminal(bare_terminal(SessionTerminalState::Intent)),
])
.unwrap();
store
.apply(&[DomainStoreMutation::PutParticipant(publication_partial())])
.unwrap();
assert!(matches!(
store.apply(&[DomainStoreMutation::PutSession(session(
SessionPhase::Attached
))]),
Err(DomainStoreError::InvalidRow("Session transition"))
));
assert!(matches!(
store.apply(&[DomainStoreMutation::PutParticipant(
Participant::snapshot(
GenerationId::new(7),
ParticipantRole::Publication,
ParticipantState::Clean,
Some(ParticipantOwnership::Publication(PublicationOwnership {
staged_socket_path: None,
staged_identity_path: None,
socket_path: None,
identity_path: None,
socket_identity: None,
identity_file_identity: None,
})),
)
.unwrap(),
)]),
Err(DomainStoreError::Transition(
RecordTransitionError::AbsenceProofRequired
))
));
assert!(matches!(
store.apply(&[DomainStoreMutation::PutMailbox(mailbox(
ParticipantState::Clean
))]),
Err(DomainStoreError::InvalidRow("Mailbox transition"))
));
assert!(matches!(
store.apply(&[DomainStoreMutation::PutSessionTerminal(bare_terminal(
SessionTerminalState::Clean,
))]),
Err(DomainStoreError::Transition(
RecordTransitionError::AbsenceProofRequired
))
));
store
.apply(&[DomainStoreMutation::CleanSessionTerminalAfterAbsence(
bare_terminal(SessionTerminalState::Clean),
)])
.unwrap();
let snapshot = store.snapshot().unwrap();
assert_eq!(
snapshot.session_terminals[0].state(),
SessionTerminalState::Clean
);
}
#[test]
fn every_table_is_created_in_the_first_version_document() {
let directory = TestDirectory::new("tables");
drop(open_store(&directory));
let database = Database::open(directory.path("domain.redb")).unwrap();
let read = database.begin_read().unwrap();
for name in [
METADATA.name(),
DOMAINS.name(),
GENERATIONS.name(),
PARTICIPANTS.name(),
SESSIONS.name(),
SESSION_TERMINALS.name(),
MAILBOXES.name(),
] {
assert!(
read.list_tables()
.unwrap()
.any(|handle| handle.name() == name)
);
}
assert!(read.open_table(FOREIGN).is_err());
}

View file

@ -0,0 +1,4 @@
#![doc = "Durable Domain state storage and offline maintenance."]
pub mod domain_store;
pub mod state_maintenance;

View file

@ -0,0 +1,497 @@
use std::ffi::OsString;
use std::fmt;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use lazaret_core::configuration::ResolvedDomainConfiguration;
use lazaret_core::records::{
DurableDecision, ParticipantState, SessionPhase, SessionTerminalState,
};
pub use lazaret_state_anchor::StateAnchorEntryKind;
use lazaret_state_anchor::{
StateAnchorEntry, StateAnchorInspection, StateAnchorLayout, StateAnchorResetScope,
require_no_orphan_reset_quarantine, require_state_anchor_absent,
};
use crate::domain_store::{HostCoordinatorDomainStore, StateResetIntentV1, StateResetScopeV1};
#[derive(Debug)]
pub struct StateMaintenanceError(String);
impl fmt::Display for StateMaintenanceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for StateMaintenanceError {}
fn failure(error: impl fmt::Display) -> StateMaintenanceError {
StateMaintenanceError(error.to_string())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StateAnchorReport {
pub container_path: String,
pub changes: Vec<StateAnchorEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DomainStateReport {
pub profile: String,
pub workspace: Vec<u8>,
pub domain_id: [u8; 32],
pub anchors: Vec<StateAnchorReport>,
}
/// Returns an empty report only after proving that no physical State Anchor exists.
///
/// # Errors
/// Refuses changed authority, links, inaccessible paths, or any persistent or runtime anchor.
pub fn inspect_absent_domain_state(
configuration: &ResolvedDomainConfiguration,
) -> Result<DomainStateReport, StateMaintenanceError> {
require_state_anchor_absent(configuration).map_err(failure)?;
Ok(report(
configuration,
&StateAnchorInspection {
entries: Vec::new(),
},
))
}
/// Recovers a durable reset before any maintenance observer or runtime mount can expose state.
///
/// # Errors
/// Refuses malformed intent, changed authority, unsupported scope, orphan quarantine, or cleanup
/// failure.
pub fn recover_state_reset(
configuration: &ResolvedDomainConfiguration,
store: &HostCoordinatorDomainStore,
) -> Result<(), StateMaintenanceError> {
let Some(intent) = store.state_reset_intent().map_err(failure)? else {
return require_no_orphan_reset_quarantine(configuration).map_err(failure);
};
require_intent(configuration, &intent)?;
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
complete_intent(&layout, &intent)?;
store.finish_state_reset(&intent).map_err(failure)
}
/// Returns content-free State Anchor metadata after mandatory recovery and structural checks.
///
/// # Errors
/// Refuses active or malformed Domain state, changed authority, mounts, links in fixed paths, and
/// unsupported upper-layer entries.
pub fn inspect_domain_state(
configuration: &ResolvedDomainConfiguration,
store: &HostCoordinatorDomainStore,
) -> Result<DomainStateReport, StateMaintenanceError> {
recover_state_reset(configuration, store)?;
require_inactive(store)?;
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
let inspection = layout.inspect_state().map_err(failure)?;
Ok(report(configuration, &inspection))
}
/// Commits and completes one all-or-selected State Anchor reset generation.
///
/// # Errors
/// Refuses active state, an arbitrary target, malformed durable state, changed authority, mounts,
/// links in fixed paths, quarantine collisions, and unsupported upper-layer entries.
pub fn reset_domain_state(
configuration: &ResolvedDomainConfiguration,
store: &HostCoordinatorDomainStore,
scope: StateResetScopeV1,
) -> Result<DomainStateReport, StateMaintenanceError> {
recover_state_reset(configuration, store)?;
require_inactive(store)?;
validate_scope(configuration, &scope)?;
let intent = StateResetIntentV1::new(configuration.domain_id(), scope);
store.begin_state_reset(&intent).map_err(failure)?;
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
complete_intent(&layout, &intent)?;
store.finish_state_reset(&intent).map_err(failure)?;
inspect_domain_state(configuration, store)
}
fn complete_intent(
layout: &StateAnchorLayout<'_>,
intent: &StateResetIntentV1,
) -> Result<(), StateMaintenanceError> {
match intent.scope() {
StateResetScopeV1::All => layout
.complete_state_reset(StateAnchorResetScope::All)
.map_err(failure),
StateResetScopeV1::Selected(path) => {
let path = PathBuf::from(OsString::from_vec(path.clone()));
layout
.complete_state_reset(StateAnchorResetScope::Selected(&path))
.map_err(failure)
}
}
}
fn require_intent(
configuration: &ResolvedDomainConfiguration,
intent: &StateResetIntentV1,
) -> Result<(), StateMaintenanceError> {
if intent.domain_id() != configuration.domain_id() {
return Err(failure(
"reset intent Domain differs from resolved authority",
));
}
validate_scope(configuration, intent.scope())
}
fn validate_scope(
configuration: &ResolvedDomainConfiguration,
scope: &StateResetScopeV1,
) -> Result<(), StateMaintenanceError> {
let StateResetScopeV1::Selected(bytes) = scope else {
return Ok(());
};
let selected = PathBuf::from(OsString::from_vec(bytes.clone()));
if configuration
.fingerprint_record()
.persistent_dirs
.iter()
.any(|configured| Path::new(configured) == selected)
{
Ok(())
} else {
Err(failure(
"reset target is not an exact configured persistent container path",
))
}
}
fn require_inactive(store: &HostCoordinatorDomainStore) -> Result<(), StateMaintenanceError> {
let snapshot = store.snapshot().map_err(failure)?;
let inactive = snapshot
.generations
.iter()
.all(|generation| generation.durable_decision == DurableDecision::Clean)
&& snapshot
.participants
.iter()
.all(|participant| participant.state() == ParticipantState::Clean)
&& snapshot.sessions.iter().all(|session| {
matches!(
session.session_phase,
SessionPhase::Closed | SessionPhase::Failed
)
})
&& snapshot
.session_terminals
.iter()
.all(|terminal| terminal.state() == SessionTerminalState::Clean)
&& snapshot
.mailboxes
.iter()
.all(|mailbox| mailbox.state == ParticipantState::Clean);
if inactive {
Ok(())
} else {
Err(failure("Domain is active or has unfinished runtime state"))
}
}
fn report(
configuration: &ResolvedDomainConfiguration,
inspection: &StateAnchorInspection,
) -> DomainStateReport {
let anchors = configuration
.fingerprint_record()
.persistent_dirs
.iter()
.map(|container_path| {
let relative = container_path.trim_start_matches('/').as_bytes();
let path = Path::new(container_path.trim_start_matches('/'));
let mut whiteout = path
.parent()
.unwrap_or(Path::new(""))
.as_os_str()
.as_encoded_bytes()
.to_vec();
if !whiteout.is_empty() {
whiteout.push(b'/');
}
whiteout.extend_from_slice(b".wh.");
whiteout.extend_from_slice(
path.file_name()
.expect("validated persistent path has a name")
.as_encoded_bytes(),
);
let changes = inspection
.entries
.iter()
.filter(|entry| {
entry.path == relative
|| entry.path == whiteout
|| entry
.path
.strip_prefix(relative)
.is_some_and(|suffix| suffix.first() == Some(&b'/'))
})
.cloned()
.collect();
StateAnchorReport {
container_path: container_path.clone(),
changes,
}
})
.collect();
DomainStateReport {
profile: configuration.selected_profile().to_owned(),
workspace: configuration
.workspace()
.canonical_label()
.as_bytes()
.to_vec(),
domain_id: *configuration.domain_id().as_bytes(),
anchors,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use lazaret_core::configuration::{
ConfigurationLoadRequest, ResolvedDomainConfiguration, load_trusted_configuration,
};
use lazaret_core::identity::{
ConfigurationFingerprint, CoordinatorId, GenerationId, NetworkSidecarId, SandboxId,
};
use lazaret_core::records::{Domain, Generation};
use lazaret_linux::filesystem::try_exclusive_flock;
use rustix::fs::{FileType, Mode, XattrFlags, fsetxattr, makedev, mknodat};
use super::*;
#[derive(Clone, Copy)]
enum EntryCase {
Directory,
File,
Symlink,
WhiteoutFile,
WhiteoutDevice,
}
#[test]
fn every_reset_boundary_recovers_to_new_empty() {
for entry_case in [
EntryCase::Directory,
EntryCase::File,
EntryCase::Symlink,
EntryCase::WhiteoutFile,
EntryCase::WhiteoutDevice,
] {
for boundary in 0..9 {
let root = tempfile::tempdir().unwrap();
let configuration = test_configuration(root.path());
let store = open_store(&configuration);
let layout = StateAnchorLayout::create(&configuration).unwrap();
let anchor = domain_root(&configuration).join("state-anchor");
let upper = anchor.join("upper");
fs::create_dir(upper.join("unrelated")).unwrap();
fs::write(upper.join("unrelated/value"), b"unchanged").unwrap();
create_entry(&upper, entry_case);
let intent = StateResetIntentV1::new(
configuration.domain_id(),
StateResetScopeV1::Selected(b"/usr".to_vec()),
);
store.begin_state_reset(&intent).unwrap();
stage_boundary(&anchor, &upper, entry_case, boundary);
if boundary == 8 {
store.finish_state_reset(&intent).unwrap();
}
drop(layout);
recover_state_reset(&configuration, &store).unwrap();
assert!(!upper.join("usr").exists());
assert!(!upper.join("usr").is_symlink());
assert!(!upper.join(".wh.usr").exists());
assert_eq!(
fs::read(upper.join("unrelated/value")).unwrap(),
b"unchanged"
);
assert!(store.state_reset_intent().unwrap().is_none());
assert!(!anchor.join("reset-quarantine").exists());
}
}
}
#[test]
fn corrupt_mismatched_and_orphan_recovery_state_refuses() {
let root = tempfile::tempdir().unwrap();
let configuration = test_configuration(root.path());
let store = open_store(&configuration);
let foreign = StateResetIntentV1::new(
configuration.domain_id(),
StateResetScopeV1::Selected(b"/etc".to_vec()),
);
store.begin_state_reset(&foreign).unwrap();
assert!(recover_state_reset(&configuration, &store).is_err());
let root = tempfile::tempdir().unwrap();
let configuration = test_configuration(root.path());
let store = open_store(&configuration);
StateAnchorLayout::create(&configuration).unwrap();
fs::create_dir(domain_root(&configuration).join("state-anchor/reset-quarantine")).unwrap();
assert!(recover_state_reset(&configuration, &store).is_err());
}
#[test]
fn active_domain_refuses_inspection_and_reset() {
let root = tempfile::tempdir().unwrap();
let configuration = test_configuration(root.path());
let store = open_store(&configuration);
store
.apply(&[crate::domain_store::DomainStoreMutation::PutGeneration(
Generation {
domain_id: configuration.domain_id(),
generation_id: GenerationId::new(1),
configuration_fingerprint: ConfigurationFingerprint::new([2; 32]),
coordinator_id: CoordinatorId::new([3; 32]),
network_sidecar_id: NetworkSidecarId::new([4; 32]),
sandbox_id: SandboxId::new([5; 32]),
durable_decision: DurableDecision::None,
},
)])
.unwrap();
assert!(inspect_domain_state(&configuration, &store).is_err());
assert!(reset_domain_state(&configuration, &store, StateResetScopeV1::All).is_err());
assert!(store.state_reset_intent().unwrap().is_none());
}
fn create_entry(upper: &Path, entry_case: EntryCase) {
match entry_case {
EntryCase::Directory => {
fs::create_dir(upper.join("usr")).unwrap();
fs::write(upper.join("usr/value"), b"state").unwrap();
fs::write(upper.join("usr/.wh..wh..opq"), b"").unwrap();
let directory = File::open(upper.join("usr")).unwrap();
fsetxattr(
directory,
"user.fuseoverlayfs.opaque",
b"y",
XattrFlags::empty(),
)
.unwrap();
}
EntryCase::File => fs::write(upper.join("usr"), b"state").unwrap(),
EntryCase::Symlink => symlink("/foreign", upper.join("usr")).unwrap(),
EntryCase::WhiteoutFile => fs::write(upper.join(".wh.usr"), b"").unwrap(),
EntryCase::WhiteoutDevice => mknodat(
rustix::fs::CWD,
upper.join("usr"),
FileType::CharacterDevice,
Mode::empty(),
makedev(0, 0),
)
.unwrap(),
}
}
fn stage_boundary(anchor: &Path, upper: &Path, entry_case: EntryCase, boundary: usize) {
if boundary == 0 {
return;
}
let quarantine = anchor.join("reset-quarantine");
fs::create_dir(&quarantine).unwrap();
if boundary == 1 {
return;
}
let (source, destination) = match entry_case {
EntryCase::WhiteoutFile => (upper.join(".wh.usr"), quarantine.join("whiteout")),
_ => (upper.join("usr"), quarantine.join("entry")),
};
fs::rename(source, &destination).unwrap();
if boundary <= 3 {
return;
}
remove_entry(&destination);
if boundary <= 5 {
return;
}
fs::remove_dir(&quarantine).unwrap();
}
fn remove_entry(path: &Path) {
let metadata = fs::symlink_metadata(path).unwrap();
if metadata.file_type().is_dir() {
fs::remove_dir_all(path).unwrap();
} else {
fs::remove_file(path).unwrap();
}
}
fn open_store(configuration: &ResolvedDomainConfiguration) -> HostCoordinatorDomainStore {
let root = domain_root(configuration);
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(root.join("domain.lock"))
.unwrap();
HostCoordinatorDomainStore::open(
try_exclusive_flock(lock).unwrap(),
&root.join("domain.redb"),
&Domain {
domain_id: configuration.domain_id(),
profile_name: configuration.selected_profile().to_owned(),
canonical_workspace: configuration.workspace().canonical_label().clone(),
},
)
.unwrap()
}
fn domain_root(configuration: &ResolvedDomainConfiguration) -> PathBuf {
PathBuf::from(std::ffi::OsString::from_vec(
configuration
.persistent_domain_root()
.canonical_label()
.as_bytes()
.to_vec(),
))
}
fn test_configuration(root: &Path) -> ResolvedDomainConfiguration {
let configuration_directory = root.join("configuration");
let workspace = root.join("workspace");
let runtime = root.join("runtime");
fs::create_dir(&configuration_directory).unwrap();
fs::create_dir(&workspace).unwrap();
fs::create_dir(&runtime).unwrap();
let configuration_path = configuration_directory.join("lazaret.toml");
fs::write(
&configuration_path,
format!(
"schema = \"lazaret-trusted-host-config-v1\"\n[storage]\ndata_root = \"{}\"\n[network]\ndefault_allow = [\"192.0.2.10/32\"]\n[profiles.default]\nimage = \"example.invalid/state:v1\"\npersistent_dirs = [\"/usr\"]\n[profiles.default.readiness]\ndenied = [{{ address = \"10.20.30.40\", port = 8443 }}]\npermitted = [{{ address = \"192.0.2.10\", port = 443 }}]\ndns = [{{ resolver = \"198.51.100.53\", query = \"example.invalid\" }}]\n",
root.join("data").display()
),
)
.unwrap();
load_trusted_configuration(ConfigurationLoadRequest {
explicit_configuration_path: Some(configuration_path),
selected_profile: "default".into(),
workspace,
home: root.join("home"),
xdg_config_home: None,
xdg_data_home: None,
xdg_runtime_dir: runtime,
environment: BTreeMap::new(),
sandbox_layout: lazaret_core::configuration::SandboxLayoutV1::default_layout().unwrap(),
})
.unwrap()
}
}

Some files were not shown because too many files have changed in this diff Show more