lazaret/crates/lazaret-runtime-supervisor/src/sandbox.rs

1327 lines
43 KiB
Rust

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;
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};
pub const WORKSPACE_DESTINATION: &str = "/workspace";
pub const CONTROL_ROOT_DESTINATION: &str = "/run/lazaret";
pub const CREDENTIALS_DESTINATION: &str = "/run/lazaret/credentials";
pub const MAILBOX_REQUESTS_DESTINATION: &str = "/run/lazaret/mailbox/requests";
pub const MAILBOX_RESULTS_DESTINATION: &str = "/run/lazaret/mailbox/results";
pub const ESCALATION_CLIENT_DESTINATION: &str = "/run/lazaret/bin/lazaret-escalate";
pub const TERMINFO_ROOT_DESTINATION: &str = "/run/lazaret/terminfo";
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<String>) -> 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<String>,
}
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<String>,
arguments: impl IntoIterator<Item = String>,
) -> Result<Self, SandboxBuildError> {
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<Self, SandboxBuildError> {
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<Vec<u8>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RuntimeHandoffSource {
relative_path: PathBuf,
expected_type: HandoffPathType,
build_identity: Option<FileIdentity>,
expected_bytes: Option<Vec<u8>>,
}
#[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<PathAuthorityIdentity>,
}
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<DerivedHandoffAuthority>,
}
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(changed_configuration_authority)?;
for volume in self.configuration.volumes() {
volume
.open_source()
.map_err(changed_configuration_authority)?;
}
let runtime_root = self
.configuration
.runtime_domain_root()
.open_authority()
.map_err(changed_configuration_authority)?;
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)? != 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<String>,
compiled_entry: Vec<u8>,
}
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<String>,
color_term: Option<String>,
compiled_entry: Vec<u8>,
) -> Result<Self, SandboxBuildError> {
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<String>,
compiled_entry: Vec<u8>,
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<StagedHostTerminal, SandboxBuildError> {
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<StagedHostTerminal>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RunscCommand {
program: OsString,
arguments: Vec<OsString>,
bundle_authorization: [u8; 32],
}
impl RunscCommand {
pub(crate) fn new(
program: impl Into<OsString>,
arguments: impl IntoIterator<Item = OsString>,
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<u8>,
command: RunscCommand,
domain_id: DomainId,
configuration_fingerprint: ConfigurationFingerprint,
configuration_authority: ConfigurationAuthorityIdentity,
runtime_sources: Vec<RuntimeHandoffSource>,
}
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<SandboxHandoffAuthority<'configuration>, 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(changed_configuration_authority)?;
for volume in configuration.volumes() {
volume
.open_source()
.map_err(changed_configuration_authority)?;
}
let runtime_root = configuration
.runtime_domain_root()
.open_authority()
.map_err(changed_configuration_authority)?;
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)?;
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::<Result<_, SandboxBuildError>>()?;
Ok(SandboxHandoffAuthority {
configuration,
domain_id: self.domain_id,
configuration_fingerprint: self.configuration_fingerprint,
configuration_authority: self.configuration_authority.clone(),
command: self.command.clone(),
derived,
})
}
}
/// 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 production_sandbox_layout() -> Result<SandboxLayoutV1, SandboxBuildError> {
SandboxLayoutV1::from_production_layout([
WORKSPACE_DESTINATION.to_owned(),
CONTROL_ROOT_DESTINATION.to_owned(),
"/proc".to_owned(),
"/sys".to_owned(),
"/dev".to_owned(),
])
.map_err(|error| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidPath,
format!("production 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<SandboxBundle, SandboxBuildError> {
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 paths = runtime_paths(request.configuration, request.container_id)?;
let mounts = build_mounts(
request.configuration,
request.terminal.as_ref(),
&paths.control_sources,
)?;
let process = build_process(
request.configuration,
request.root_process,
request.terminal.as_ref(),
)?;
let linux = build_linux()?;
let root = RootBuilder::default()
.path(paths.rootfs.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,
})
}
struct RuntimePaths {
container_name: String,
bundle: PathBuf,
runsc_root: PathBuf,
rootfs: PathBuf,
control_sources: PathBuf,
}
fn handoff_runtime_sources(
paths: &RuntimePaths,
terminal: Option<&StagedHostTerminal>,
specification_json: &[u8],
) -> Vec<RuntimeHandoffSource> {
let container = Path::new("sandbox-control").join(&paths.container_name);
let mut sources = [
(
Path::new("bundles").join(&paths.container_name),
HandoffPathType::Directory,
),
(
Path::new("bundles")
.join(&paths.container_name)
.join("config.json"),
HandoffPathType::RegularFile,
),
(PathBuf::from("runsc"), HandoffPathType::Directory),
(
PathBuf::from("state-anchor/merged"),
HandoffPathType::Directory,
),
(container.clone(), HandoffPathType::Directory),
(container.join("credentials"), HandoffPathType::Directory),
(
container.join("mailbox/requests"),
HandoffPathType::Directory,
),
(
container.join("mailbox/results"),
HandoffPathType::Directory,
),
(
container.join("bin/lazaret-escalate"),
HandoffPathType::RegularFile,
),
]
.into_iter()
.map(|(relative_path, expected_type)| {
let expected_bytes = (relative_path
== Path::new("bundles")
.join(&paths.container_name)
.join("config.json"))
.then(|| specification_json.to_vec());
RuntimeHandoffSource {
relative_path,
expected_type,
build_identity: None,
expected_bytes,
}
})
.collect::<Vec<_>>();
if let Some(terminal) = terminal {
sources.push(RuntimeHandoffSource {
relative_path: terminal.relative_source.clone(),
expected_type: HandoffPathType::RegularFile,
build_identity: Some(terminal.identity),
expected_bytes: None,
});
}
sources
}
fn open_runtime_source(
runtime_root: &OwnedFd,
relative_path: &Path,
expected_type: HandoffPathType,
readable: bool,
) -> Result<OwnedFd, SandboxBuildError> {
let descriptor = openat2(
runtime_root,
relative_path,
if readable {
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW
} else {
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW
},
Mode::empty(),
ResolveFlags::BENEATH | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
)
.map_err(staging_error)?;
let stat = rustix::fs::fstat(&descriptor).map_err(staging_error)?;
let actual = FileType::from_raw_mode(stat.st_mode);
let matches = match expected_type {
HandoffPathType::Directory => actual.is_dir(),
HandoffPathType::RegularFile => actual.is_file(),
};
if !matches {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"runtime handoff source changed type",
));
}
Ok(descriptor)
}
fn require_exact_bytes(descriptor: OwnedFd, expected: &[u8]) -> Result<(), SandboxBuildError> {
let limit = u64::try_from(expected.len())
.ok()
.and_then(|length| length.checked_add(1))
.ok_or_else(|| {
SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"staged OCI configuration length is invalid",
)
})?;
let mut observed = Vec::new();
File::from(descriptor)
.take(limit)
.read_to_end(&mut observed)
.map_err(staging_error)?;
if observed != expected {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"staged OCI configuration differs from its bundle",
));
}
Ok(())
}
fn descriptor_identity(descriptor: &OwnedFd) -> Result<FileIdentity, SandboxBuildError> {
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",
));
}
Ok(FileIdentity {
device: stat.st_dev,
inode: stat.st_ino,
mount_id: extended.stx_mnt_id,
})
}
fn runtime_paths(
configuration: &ResolvedDomainConfiguration,
container_id: ContainerId,
) -> Result<RuntimePaths, SandboxBuildError> {
let runtime_domain = host_path(configuration.runtime_domain_root().canonical_label())?;
let container_name = hex_lower(container_id.as_bytes());
Ok(RuntimePaths {
bundle: runtime_domain.join("bundles").join(&container_name),
runsc_root: runtime_domain.join("runsc"),
rootfs: runtime_domain.join("state-anchor").join("merged"),
control_sources: runtime_domain.join("sandbox-control").join(&container_name),
container_name,
})
}
fn build_mounts(
configuration: &ResolvedDomainConfiguration,
terminal: Option<&StagedHostTerminal>,
control_sources: &Path,
) -> Result<Vec<Mount>, SandboxBuildError> {
let mut mounts = standard_mounts()?;
mounts.push(bind_mount(
host_path(configuration.workspace().canonical_label())?,
PathBuf::from(WORKSPACE_DESTINATION),
false,
false,
)?);
for volume in configuration.volumes() {
let record = volume.record();
mounts.push(bind_mount(
host_path(&record.canonical_host_source)?,
PathBuf::from(&record.container_path),
record.access == VolumeAccess::ReadOnly,
false,
)?);
}
mounts.extend(control_mounts(control_sources)?);
if let Some(terminal) = terminal {
mounts.push(bind_mount(
host_path(configuration.runtime_domain_root().canonical_label())?
.join(&terminal.relative_source),
terminal.destination(),
true,
true,
)?);
}
Ok(mounts)
}
fn build_linux() -> Result<oci_spec::runtime::Linux, SandboxBuildError> {
let private_namespaces = [
LinuxNamespaceType::Pid,
LinuxNamespaceType::Ipc,
LinuxNamespaceType::Uts,
LinuxNamespaceType::Mount,
LinuxNamespaceType::User,
]
.into_iter()
.map(|typ| {
LinuxNamespaceBuilder::default()
.typ(typ)
.build()
.map_err(oci_error)
})
.chain(std::iter::once(
LinuxNamespaceBuilder::default()
.typ(LinuxNamespaceType::Network)
.path(PathBuf::from(NETWORK_NAMESPACE_PATH))
.build()
.map_err(oci_error),
))
.collect::<Result<Vec<_>, _>>()?;
let identity_mapping = LinuxIdMappingBuilder::default()
.container_id(0_u32)
.host_id(0_u32)
.size(1_u32)
.build()
.map_err(oci_error)?;
LinuxBuilder::default()
.namespaces(private_namespaces)
.uid_mappings(vec![identity_mapping])
.gid_mappings(vec![identity_mapping])
.rootfs_propagation("rprivate")
.build()
.map_err(oci_error)
}
fn build_runsc_command(paths: &RuntimePaths, bundle_authorization: [u8; 32]) -> RunscCommand {
RunscCommand::new(
"runsc",
[
OsString::from(format!(
"-root={}",
paths
.runsc_root
.to_str()
.expect("validated runtime path remains UTF-8")
)),
OsString::from("-rootless"),
OsString::from("-network=host"),
OsString::from("-ignore-cgroups"),
OsString::from("run"),
OsString::from(format!(
"-bundle={}",
paths
.bundle
.to_str()
.expect("validated runtime path remains UTF-8")
)),
OsString::from(&paths.container_name),
],
bundle_authorization,
)
}
fn build_process(
configuration: &ResolvedDomainConfiguration,
root_process: RootProcess,
terminal: Option<&StagedHostTerminal>,
) -> Result<oci_spec::runtime::Process, SandboxBuildError> {
let mut environment = BTreeMap::from([("PATH".to_owned(), DEFAULT_PATH.to_owned())]);
for (name, value) in configuration.ephemeral_environment() {
let value = value.expose().to_str().ok_or_else(|| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidProcess,
"resolved environment value is not UTF-8",
)
})?;
environment.insert(name.clone(), value.to_owned());
}
if let Some(terminal) = terminal {
environment.insert("TERM".to_owned(), terminal.term.clone());
if let Some(color_term) = &terminal.color_term {
environment.insert("COLORTERM".to_owned(), color_term.clone());
} else {
environment.remove("COLORTERM");
}
environment.insert(
"TERMINFO_DIRS".to_owned(),
format!("{TERMINFO_ROOT_DESTINATION}:"),
);
}
let environment = environment
.into_iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>();
let empty = Capabilities::new();
let capabilities = LinuxCapabilitiesBuilder::default()
.bounding(empty.clone())
.effective(empty.clone())
.inheritable(empty.clone())
.permitted(empty.clone())
.ambient(empty)
.build()
.map_err(oci_error)?;
let user = UserBuilder::default()
.uid(0_u32)
.gid(0_u32)
.umask(0o022_u32)
.build()
.map_err(oci_error)?;
ProcessBuilder::default()
.terminal(false)
.user(user)
.args(root_process.arguments)
.env(environment)
.cwd(PathBuf::from(WORKSPACE_DESTINATION))
.capabilities(capabilities)
.no_new_privileges(true)
.build()
.map_err(oci_error)
}
fn standard_mounts() -> Result<Vec<Mount>, SandboxBuildError> {
[
("/proc", "proc", "proc", vec!["nosuid", "noexec", "nodev"]),
(
"/dev",
"tmpfs",
"tmpfs",
vec!["nosuid", "strictatime", "mode=755", "size=65536k"],
),
(
"/dev/pts",
"devpts",
"devpts",
vec![
"nosuid",
"noexec",
"newinstance",
"ptmxmode=0666",
"mode=0620",
],
),
(
"/dev/shm",
"tmpfs",
"shm",
vec!["nosuid", "noexec", "nodev", "mode=1777", "size=65536k"],
),
(
"/dev/mqueue",
"mqueue",
"mqueue",
vec!["nosuid", "noexec", "nodev"],
),
(
"/sys",
"sysfs",
"sysfs",
vec!["nosuid", "noexec", "nodev", "ro"],
),
]
.into_iter()
.map(|(destination, typ, source, options)| {
MountBuilder::default()
.destination(PathBuf::from(destination))
.typ(typ)
.source(PathBuf::from(source))
.options(options.into_iter().map(str::to_owned).collect::<Vec<_>>())
.build()
.map_err(oci_error)
})
.collect()
}
fn control_mounts(control_sources: &Path) -> Result<Vec<Mount>, SandboxBuildError> {
[
("credentials", CREDENTIALS_DESTINATION, true, false),
(
"mailbox/requests",
MAILBOX_REQUESTS_DESTINATION,
false,
false,
),
("mailbox/results", MAILBOX_RESULTS_DESTINATION, true, false),
(
"bin/lazaret-escalate",
ESCALATION_CLIENT_DESTINATION,
true,
false,
),
]
.into_iter()
.map(|(source, destination, read_only, no_exec)| {
bind_mount(
control_sources.join(source),
PathBuf::from(destination),
read_only,
no_exec,
)
})
.collect()
}
fn bind_mount(
source: PathBuf,
destination: PathBuf,
read_only: bool,
no_exec: bool,
) -> Result<Mount, SandboxBuildError> {
validate_absolute_normalized(&source)?;
validate_absolute_normalized(&destination)?;
let mut options = vec!["bind".to_owned(), "nosuid".to_owned(), "nodev".to_owned()];
options.push(if read_only { "ro" } else { "rw" }.to_owned());
if no_exec {
options.push("noexec".to_owned());
}
MountBuilder::default()
.destination(destination)
.typ("bind")
.source(source)
.options(options)
.build()
.map_err(oci_error)
}
fn require_network_namespace(expected: NamespaceIdentity) -> Result<(), SandboxBuildError> {
let observed = lazaret_linux::filesystem::namespace_identity(Path::new(NETWORK_NAMESPACE_PATH))
.map_err(|error| {
SandboxBuildError::new(
SandboxBuildErrorKind::NetworkNamespaceMismatch,
format!("cannot inspect Runtime Supervisor network namespace: {error}"),
)
})?;
if observed != expected {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::NetworkNamespaceMismatch,
"Runtime Supervisor network namespace identity differs from readiness evidence",
));
}
Ok(())
}
fn changed_configuration_authority(error: impl fmt::Display) -> SandboxBuildError {
SandboxBuildError::new(SandboxBuildErrorKind::ChangedAuthority, error.to_string())
}
fn oci_error(error: impl fmt::Display) -> SandboxBuildError {
SandboxBuildError::new(SandboxBuildErrorKind::OciSpecification, error.to_string())
}
fn validate_sandbox_executable(program: &str) -> Result<(), SandboxBuildError> {
validate_absolute_normalized(Path::new(program)).map_err(|_| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidProcess,
"program is not a normalized absolute sandbox path",
)
})
}
fn validate_terminal_name(term: &str) -> Result<(), SandboxBuildError> {
let valid = !term.is_empty()
&& term.len() <= 64
&& term
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.' | b'_'));
if valid {
Ok(())
} else {
Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidTerminal,
"TERM violates the conservative terminal-name grammar",
))
}
}
fn validate_compiled_terminfo(term: &str, bytes: &[u8]) -> Result<(), SandboxBuildError> {
if bytes.len() < 12 || bytes.len() > MAX_TERMINFO_BYTES {
return Err(invalid_terminfo("compiled entry has an invalid size"));
}
let magic = little_u16(bytes, 0);
let number_width = match magic {
0x011a => 2,
0x021e => 4,
_ => return Err(invalid_terminfo("compiled entry has an unknown magic")),
};
let names_size = usize::from(little_u16(bytes, 2));
let bool_count = usize::from(little_u16(bytes, 4));
let number_count = usize::from(little_u16(bytes, 6));
let string_count = usize::from(little_u16(bytes, 8));
let string_table_size = usize::from(little_u16(bytes, 10));
let names_end = 12_usize
.checked_add(names_size)
.filter(|end| *end <= bytes.len())
.ok_or_else(|| invalid_terminfo("compiled entry names exceed its length"))?;
let names = &bytes[12..names_end];
if names.last() != Some(&0)
|| !names[..names.len().saturating_sub(1)]
.split(|byte| *byte == b'|')
.any(|name| name == term.as_bytes())
{
return Err(invalid_terminfo(
"compiled entry does not name the requested terminal",
));
}
let after_bools = names_end
.checked_add(bool_count)
.ok_or_else(|| invalid_terminfo("compiled entry size overflows"))?;
let aligned = after_bools
.checked_add(after_bools % 2)
.ok_or_else(|| invalid_terminfo("compiled entry size overflows"))?;
let required = aligned
.checked_add(
number_count
.checked_mul(number_width)
.ok_or_else(|| invalid_terminfo("compiled entry size overflows"))?,
)
.and_then(|size| size.checked_add(string_count.checked_mul(2)?))
.and_then(|size| size.checked_add(string_table_size))
.ok_or_else(|| invalid_terminfo("compiled entry size overflows"))?;
if required > bytes.len() {
return Err(invalid_terminfo("compiled entry sections are truncated"));
}
Ok(())
}
fn little_u16(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
fn invalid_terminfo(detail: &str) -> SandboxBuildError {
SandboxBuildError::new(SandboxBuildErrorKind::InvalidTerminal, detail)
}
fn open_or_create_staging_directory(
parent: OwnedFd,
name: &str,
) -> Result<OwnedFd, SandboxBuildError> {
match rustix::fs::mkdirat(&parent, name, Mode::RWXU) {
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
Err(error) => return Err(staging_error(error)),
}
let directory = openat2(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
confined_resolve_flags(),
)
.map_err(staging_error)?;
let stat = rustix::fs::fstat(&directory).map_err(staging_error)?;
if !FileType::from_raw_mode(stat.st_mode).is_dir()
|| stat.st_uid != rustix::process::getuid().as_raw()
|| stat.st_mode & 0o7777 != 0o700
{
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"terminfo staging directory has unsafe ownership or mode",
));
}
Ok(directory)
}
fn stage_exact_file(
directory: &OwnedFd,
name: &str,
bytes: &[u8],
) -> Result<OwnedFd, SandboxBuildError> {
match open_staged_file(directory, name) {
Ok(descriptor) => {
require_staged_file(&descriptor)?;
if read_descriptor(descriptor)? != bytes {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"existing staged terminfo artifact has different bytes",
));
}
open_staged_file(directory, name).map_err(staging_error)
}
Err(rustix::io::Errno::NOENT) => {
let descriptor = rustix::fs::openat(
directory,
name,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::RUSR | Mode::WUSR,
)
.map_err(staging_error)?;
let mut file = File::from(descriptor);
let result = (|| {
file.write_all(bytes).map_err(staging_error)?;
rustix::fs::fchmod(file.as_fd(), Mode::RUSR).map_err(staging_error)?;
file.sync_all().map_err(staging_error)
})();
if let Err(error) = result {
drop(file);
let _ = rustix::fs::unlinkat(directory, name, AtFlags::empty());
return Err(error);
}
drop(file);
rustix::fs::fsync(directory).map_err(staging_error)?;
let descriptor = open_staged_file(directory, name).map_err(staging_error)?;
require_staged_file(&descriptor)?;
Ok(descriptor)
}
Err(error) => Err(staging_error(error)),
}
}
fn open_staged_file(directory: &OwnedFd, name: &str) -> rustix::io::Result<OwnedFd> {
openat2(
directory,
name,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
confined_resolve_flags(),
)
}
fn require_staged_file(descriptor: &OwnedFd) -> Result<(), SandboxBuildError> {
let stat = rustix::fs::fstat(descriptor).map_err(staging_error)?;
if !FileType::from_raw_mode(stat.st_mode).is_file()
|| stat.st_uid != rustix::process::getuid().as_raw()
|| stat.st_mode & 0o7777 != 0o400
|| stat.st_nlink != 1
{
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"staged terminfo artifact has unsafe type, ownership, mode, or link count",
));
}
Ok(())
}
fn read_descriptor(descriptor: OwnedFd) -> Result<Vec<u8>, SandboxBuildError> {
let mut bytes = Vec::new();
File::from(descriptor)
.take((MAX_TERMINFO_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(staging_error)?;
if bytes.len() > MAX_TERMINFO_BYTES {
return Err(invalid_terminfo("staged terminfo artifact is oversized"));
}
Ok(bytes)
}
fn confined_resolve_flags() -> ResolveFlags {
ResolveFlags::BENEATH
| ResolveFlags::NO_MAGICLINKS
| ResolveFlags::NO_SYMLINKS
| ResolveFlags::NO_XDEV
}
fn staging_error(error: impl fmt::Display) -> SandboxBuildError {
SandboxBuildError::new(SandboxBuildErrorKind::ChangedAuthority, error.to_string())
}
fn validate_absolute_normalized(path: &Path) -> Result<(), SandboxBuildError> {
if !path.is_absolute()
|| path.as_os_str().as_bytes().contains(&0)
|| path
.components()
.any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
{
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidPath,
"path is not normalized and absolute",
));
}
utf8_path(path)?;
Ok(())
}
fn utf8_path(path: &Path) -> Result<&str, SandboxBuildError> {
path.to_str().ok_or_else(|| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidPath,
"OCI JSON path is not UTF-8",
)
})
}
fn host_path(path: &lazaret_core::identity::HostPath) -> Result<PathBuf, SandboxBuildError> {
let path = PathBuf::from(OsString::from_vec(path.as_bytes().to_vec()));
validate_absolute_normalized(&path)?;
Ok(path)
}
fn hex_lower(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use fmt::Write as _;
write!(&mut output, "{byte:02x}").expect("writing to String cannot fail");
}
output
}