use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; use std::fmt; use std::fs::File; use std::io::{Read, Write}; use std::os::fd::{AsFd, OwnedFd}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Component, Path, PathBuf}; use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration, SandboxLayoutV1}; use lazaret_core::identity::{ConfigurationFingerprint, ContainerId, DomainId, NamespaceIdentity}; use lazaret_core::records::VolumeAccess; pub use lazaret_sandbox_contract::{ CONTROL_ROOT_DESTINATION, CREDENTIALS_DESTINATION, DEV_DESTINATION, ESCALATION_CLIENT_DESTINATION, HOSTNAME_DESTINATION, HOSTS_DESTINATION, MAILBOX_REQUESTS_DESTINATION, MAILBOX_RESULTS_DESTINATION, PROC_DESTINATION, RESOLV_CONF_DESTINATION, RUNTIME_FILE_DESTINATIONS, SANDBOX_INIT_DESTINATION, SYS_DESTINATION, TERMINFO_ROOT_DESTINATION, WORKSPACE_DESTINATION, }; use oci_spec::runtime::{ Capabilities, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxIdMappingBuilder, LinuxNamespaceBuilder, LinuxNamespaceType, Mount, MountBuilder, ProcessBuilder, RootBuilder, Spec, SpecBuilder, UserBuilder, }; use rustix::fs::{AtFlags, FileType, Mode, OFlags, ResolveFlags, StatxFlags, openat2, statx}; use sha2::{Digest, Sha256}; use mounts::{bind_mount, control_mounts, standard_mounts}; use runtime_sources::{ build_linux, build_mounts, build_process, build_runsc_command, descriptor_identity, handoff_runtime_sources, open_absolute_directory, open_runtime_source, require_exact_bytes, runtime_paths, }; use validation::{ changed_authority_at, changed_configuration_authority, confined_resolve_flags, hex_lower, host_path, oci_error, open_or_create_staging_directory, read_descriptor, require_network_namespace, require_staged_file, stage_exact_file, staging_error, validate_absolute_normalized, validate_compiled_terminfo, validate_sandbox_executable, validate_terminal_name, }; pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net"; const OCI_VERSION: &str = "1.2.1"; const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const MAX_TERMINFO_BYTES: usize = 1_048_576; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SandboxBuildErrorKind { InvalidProcess, InvalidPath, InvalidTerminal, ChangedAuthority, NetworkNamespaceMismatch, OciSpecification, JsonEncoding, } #[derive(Debug)] pub struct SandboxBuildError { kind: SandboxBuildErrorKind, detail: String, } impl SandboxBuildError { fn new(kind: SandboxBuildErrorKind, detail: impl Into) -> Self { Self { kind, detail: detail.into(), } } #[must_use] pub const fn kind(&self) -> SandboxBuildErrorKind { self.kind } } impl fmt::Display for SandboxBuildError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "{}: {}", error_label(self.kind), self.detail) } } impl std::error::Error for SandboxBuildError {} const fn error_label(kind: SandboxBuildErrorKind) -> &'static str { match kind { SandboxBuildErrorKind::InvalidProcess => "invalid root process", SandboxBuildErrorKind::InvalidPath => "invalid sandbox path", SandboxBuildErrorKind::InvalidTerminal => "invalid host terminal", SandboxBuildErrorKind::ChangedAuthority => "changed host authority", SandboxBuildErrorKind::NetworkNamespaceMismatch => "network namespace mismatch", SandboxBuildErrorKind::OciSpecification => "invalid OCI specification", SandboxBuildErrorKind::JsonEncoding => "OCI JSON encoding failed", } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct RootProcess { arguments: Vec, } impl RootProcess { /// Creates a direct argument vector for the terminal-neutral root process. /// /// # Errors /// The program must be an absolute normalized sandbox path and no argument may contain NUL. pub fn new( program: impl Into, arguments: impl IntoIterator, ) -> Result { let program = program.into(); validate_sandbox_executable(&program)?; let mut complete = vec![program]; for argument in arguments { if argument.as_bytes().contains(&0) { return Err(SandboxBuildError::new( SandboxBuildErrorKind::InvalidProcess, "argument contains NUL", )); } complete.push(argument); } Ok(Self { arguments: complete, }) } #[must_use] pub fn arguments(&self) -> &[String] { &self.arguments } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct FileIdentity { device: u64, inode: u64, mount_id: u64, } impl FileIdentity { fn from_descriptor(descriptor: &OwnedFd) -> Result { let stat = rustix::fs::fstat(descriptor).map_err(staging_error)?; let extended = statx(descriptor, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) .map_err(staging_error)?; if !StatxFlags::from_bits_retain(extended.stx_mask).contains(StatxFlags::MNT_ID) { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "host filesystem does not report mount identity", )); } if !FileType::from_raw_mode(stat.st_mode).is_file() { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "staged terminfo artifact is not a regular file", )); } Ok(Self { device: stat.st_dev, inode: stat.st_ino, mount_id: extended.stx_mnt_id, }) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum HandoffPathType { Directory, RegularFile, } #[derive(Clone, Debug, Eq, PartialEq)] struct DerivedHandoffAuthority { relative_path: PathBuf, expected_type: HandoffPathType, identity: FileIdentity, expected_bytes: Option>, } #[derive(Clone, Debug, Eq, PartialEq)] struct RuntimeHandoffSource { relative_path: PathBuf, expected_type: HandoffPathType, build_identity: Option, expected_bytes: Option>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct PathAuthorityIdentity { device: u64, inode: u64, mount_id: u64, } impl PathAuthorityIdentity { fn from_plan(plan: &ConfinedPathPlan) -> Self { Self { device: plan.target_device().get(), inode: plan.target_inode().get(), mount_id: plan.target_mount_id().get(), } } } #[derive(Clone, Debug, Eq, PartialEq)] struct ConfigurationAuthorityIdentity { workspace: PathAuthorityIdentity, runtime_domain: PathAuthorityIdentity, volumes: Vec, } impl ConfigurationAuthorityIdentity { fn capture(configuration: &ResolvedDomainConfiguration) -> Self { Self { workspace: PathAuthorityIdentity::from_plan(configuration.workspace()), runtime_domain: PathAuthorityIdentity::from_plan(configuration.runtime_domain_root()), volumes: configuration .volumes() .iter() .map(|volume| PathAuthorityIdentity::from_plan(volume.source_plan())) .collect(), } } } pub struct SandboxHandoffAuthority<'configuration> { configuration: &'configuration ResolvedDomainConfiguration, domain_id: DomainId, configuration_fingerprint: ConfigurationFingerprint, configuration_authority: ConfigurationAuthorityIdentity, command: RunscCommand, derived: Vec, prepared_root: PathBuf, prepared_root_identity: FileIdentity, } impl SandboxHandoffAuthority<'_> { /// Revalidates every pathname source immediately before attached runsc is released. /// /// # Errors /// Refuses changed workspace, volume, Runtime Domain, bundle, rootfs, control, or terminal /// authority. pub fn revalidate(&self) -> Result<(), SandboxBuildError> { if self.configuration.domain_id() != self.domain_id || self.configuration.configuration_fingerprint() != self.configuration_fingerprint || ConfigurationAuthorityIdentity::capture(self.configuration) != self.configuration_authority { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "sandbox handoff configuration does not match its bundle", )); } self.configuration .workspace() .open_authority() .map_err(|error| changed_authority_at("workspace", error))?; for volume in self.configuration.volumes() { volume .open_source() .map_err(|error| changed_authority_at("volume", error))?; } let prepared_root = open_absolute_directory(&self.prepared_root)?; if descriptor_identity(&prepared_root).map_err(|error| { SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, format!("prepared root identity: {error}"), ) })? != self.prepared_root_identity { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "prepared root changed identity after bundle construction", )); } let runtime_root = self .configuration .runtime_domain_root() .open_authority() .map_err(|error| changed_authority_at("Runtime Domain", error))?; for authority in &self.derived { let descriptor = open_runtime_source( &runtime_root, &authority.relative_path, authority.expected_type, authority.expected_bytes.is_some(), )?; if descriptor_identity(&descriptor).map_err(|error| { SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, format!( "runtime handoff source {} identity: {error}", authority.relative_path.display() ), ) })? != authority.identity { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "runtime handoff source changed identity", )); } if let Some(expected) = &authority.expected_bytes { require_exact_bytes(descriptor, expected)?; } } Ok(()) } #[must_use] pub const fn domain_id(&self) -> DomainId { self.domain_id } #[must_use] pub fn matches_command(&self, command: &RunscCommand) -> bool { self.command == *command } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct HostTerminal { term: String, color_term: Option, compiled_entry: Vec, } impl HostTerminal { /// Accepts one bounded compiled terminfo entry without accepting a host path. /// /// # Errors /// Invalid names, colors, terminfo headers, aliases, and oversized entries are refused. pub fn from_compiled_entry( term: impl Into, color_term: Option, compiled_entry: Vec, ) -> Result { let term = term.into(); validate_terminal_name(&term)?; if color_term.as_deref().is_some_and(|value| { value.is_empty() || value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) }) { return Err(SandboxBuildError::new( SandboxBuildErrorKind::InvalidTerminal, "COLORTERM contains control bytes or is empty", )); } validate_compiled_terminfo(&term, &compiled_entry)?; Ok(Self { term, color_term, compiled_entry, }) } } #[derive(Debug)] pub struct StagedHostTerminal { term: String, color_term: Option, compiled_entry: Vec, relative_source: PathBuf, identity: FileIdentity, } impl StagedHostTerminal { fn revalidate( &self, configuration: &ResolvedDomainConfiguration, ) -> Result<(), SandboxBuildError> { let root = configuration .runtime_domain_root() .open_authority() .map_err(changed_configuration_authority)?; let descriptor = openat2( root, &self.relative_source, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), confined_resolve_flags(), ) .map_err(staging_error)?; require_staged_file(&descriptor)?; if FileIdentity::from_descriptor(&descriptor)? != self.identity || read_descriptor(descriptor)? != self.compiled_entry { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "staged terminfo artifact changed identity or bytes", )); } Ok(()) } fn destination(&self) -> PathBuf { let bucket = char::from(self.term.as_bytes()[0]).to_string(); Path::new(TERMINFO_ROOT_DESTINATION) .join(bucket) .join(&self.term) } } /// Copies validated terminfo bytes into the generation's fixed control authority. /// /// # Errors /// Refuses changed Runtime Domain authority, unsafe staging objects, or byte mismatches. pub fn stage_host_terminal( configuration: &ResolvedDomainConfiguration, container_id: ContainerId, terminal: HostTerminal, ) -> Result { let container_name = hex_lower(container_id.as_bytes()); let bucket = char::from(terminal.term.as_bytes()[0]).to_string(); let components = [ "sandbox-control", container_name.as_str(), "terminfo", bucket.as_str(), ]; let mut directory = configuration .runtime_domain_root() .open_authority() .map_err(changed_configuration_authority)?; for component in components { directory = open_or_create_staging_directory(directory, component)?; } let descriptor = stage_exact_file(&directory, &terminal.term, &terminal.compiled_entry)?; let identity = FileIdentity::from_descriptor(&descriptor)?; if read_descriptor(descriptor)? != terminal.compiled_entry { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "staged terminfo bytes differ from the selected entry", )); } Ok(StagedHostTerminal { relative_source: Path::new("sandbox-control") .join(container_name) .join("terminfo") .join(bucket) .join(&terminal.term), term: terminal.term, color_term: terminal.color_term, compiled_entry: terminal.compiled_entry, identity, }) } pub struct SandboxBuildRequest<'a> { pub configuration: &'a ResolvedDomainConfiguration, pub container_id: ContainerId, pub expected_network_namespace: NamespaceIdentity, pub root_process: RootProcess, pub terminal: Option, pub prepared_root: PathBuf, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct RunscCommand { program: OsString, arguments: Vec, bundle_authorization: [u8; 32], } impl RunscCommand { pub(crate) fn new( program: impl Into, arguments: impl IntoIterator, bundle_authorization: [u8; 32], ) -> Self { Self { program: program.into(), arguments: arguments.into_iter().collect(), bundle_authorization, } } #[must_use] pub fn program(&self) -> &OsStr { &self.program } #[must_use] pub fn arguments(&self) -> &[OsString] { &self.arguments } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct SandboxBundle { specification: Spec, specification_json: Vec, command: RunscCommand, domain_id: DomainId, configuration_fingerprint: ConfigurationFingerprint, configuration_authority: ConfigurationAuthorityIdentity, runtime_sources: Vec, prepared_root: PathBuf, prepared_root_identity: FileIdentity, } impl SandboxBundle { #[must_use] pub const fn specification(&self) -> &Spec { &self.specification } #[must_use] pub fn specification_json(&self) -> &[u8] { &self.specification_json } #[must_use] pub const fn command(&self) -> &RunscCommand { &self.command } /// Captures the exact staged sources that must survive until attached runsc release. /// /// # Errors /// Refuses absent, changed, symlinked, or incorrectly typed handoff sources. pub fn capture_handoff_authority<'configuration>( &self, configuration: &'configuration ResolvedDomainConfiguration, ) -> Result, SandboxBuildError> { if configuration.domain_id() != self.domain_id || configuration.configuration_fingerprint() != self.configuration_fingerprint || ConfigurationAuthorityIdentity::capture(configuration) != self.configuration_authority { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "sandbox handoff configuration does not match its bundle", )); } configuration .workspace() .open_authority() .map_err(|error| changed_authority_at("workspace", error))?; for volume in configuration.volumes() { volume .open_source() .map_err(|error| changed_authority_at("volume", error))?; } let runtime_root = configuration .runtime_domain_root() .open_authority() .map_err(|error| changed_authority_at("Runtime Domain", error))?; let prepared_root = open_absolute_directory(&self.prepared_root)?; if descriptor_identity(&prepared_root).map_err(|error| { SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, format!("prepared root identity: {error}"), ) })? != self.prepared_root_identity { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "prepared root changed identity after bundle construction", )); } let derived = self .runtime_sources .iter() .map(|source| { let descriptor = open_runtime_source( &runtime_root, &source.relative_path, source.expected_type, source.expected_bytes.is_some(), )?; let identity = descriptor_identity(&descriptor).map_err(|error| { SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, format!( "runtime handoff source {} identity: {error}", source.relative_path.display() ), ) })?; if source .build_identity .is_some_and(|expected| expected != identity) { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "staged handoff source changed identity after bundle construction", )); } if let Some(expected) = &source.expected_bytes { require_exact_bytes(descriptor, expected)?; } Ok(DerivedHandoffAuthority { relative_path: source.relative_path.clone(), expected_type: source.expected_type, identity, expected_bytes: source.expected_bytes.clone(), }) }) .collect::>()?; Ok(SandboxHandoffAuthority { configuration, domain_id: self.domain_id, configuration_fingerprint: self.configuration_fingerprint, configuration_authority: self.configuration_authority.clone(), command: self.command.clone(), derived, prepared_root: self.prepared_root.clone(), prepared_root_identity: self.prepared_root_identity, }) } } /// Returns the complete protected destination roots used by bundle construction. /// /// # Errors /// Returns an error if the fixed layout violates the configuration layout contract. pub fn default_sandbox_layout() -> Result { lazaret_sandbox_contract::default_sandbox_layout().map_err(|error| { SandboxBuildError::new( SandboxBuildErrorKind::InvalidPath, format!("default sandbox layout is invalid: {error}"), ) }) } /// Builds deterministic OCI bytes and the direct attached-runsc argument vector. /// /// # Errors /// Refuses changed host authority, namespace mismatch, invalid paths, or encoding failures. pub fn build_sandbox_bundle( request: SandboxBuildRequest<'_>, ) -> Result { let workspace_source = host_path(request.configuration.workspace().canonical_label())?; build_sandbox_bundle_from_source(request, workspace_source) } /// Builds deterministic OCI bytes using a generation-local routed workspace source. /// /// # Errors /// Refuses an invalid source, changed host authority, namespace mismatch, or encoding failure. pub fn build_sandbox_bundle_with_workspace( request: SandboxBuildRequest<'_>, workspace_source: PathBuf, ) -> Result { build_sandbox_bundle_from_source(request, workspace_source) } fn build_sandbox_bundle_from_source( request: SandboxBuildRequest<'_>, workspace_source: PathBuf, ) -> Result { require_network_namespace(request.expected_network_namespace)?; request .configuration .workspace() .open_authority() .map_err(changed_configuration_authority)?; for volume in request.configuration.volumes() { volume .open_source() .map_err(changed_configuration_authority)?; } if let Some(terminal) = &request.terminal { terminal.revalidate(request.configuration)?; } let prepared_root = open_absolute_directory(&request.prepared_root)?; let prepared_root_identity = descriptor_identity(&prepared_root)?; let paths = runtime_paths(request.configuration, request.container_id)?; let mounts = build_mounts( request.configuration, request.terminal.as_ref(), &paths.control_sources, workspace_source, )?; let process = build_process( request.configuration, request.root_process, request.terminal.as_ref(), )?; let linux = build_linux()?; let root = RootBuilder::default() .path(request.prepared_root.clone()) .readonly(false) .build() .map_err(oci_error)?; let specification = SpecBuilder::default() .version(OCI_VERSION) .root(root) .mounts(mounts) .process(process) .hostname("lazaret") .linux(linux) .build() .map_err(oci_error)?; let specification_json = serde_json::to_vec(&specification).map_err(|error| { SandboxBuildError::new(SandboxBuildErrorKind::JsonEncoding, error.to_string()) })?; let bundle_authorization = Sha256::digest(&specification_json).into(); let command = build_runsc_command(&paths, bundle_authorization); let runtime_sources = handoff_runtime_sources(&paths, request.terminal.as_ref(), &specification_json); Ok(SandboxBundle { specification, specification_json, command, domain_id: request.configuration.domain_id(), configuration_fingerprint: request.configuration.configuration_fingerprint(), configuration_authority: ConfigurationAuthorityIdentity::capture(request.configuration), runtime_sources, prepared_root: request.prepared_root, prepared_root_identity, }) } mod mounts; mod runtime_sources; mod validation;