use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; use std::fmt; use std::fs::Metadata; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::fs::MetadataExt; use std::path::{Component, Path, PathBuf}; use lazaret_core::configuration::{ResolvedDomainConfiguration, SandboxLayoutV1}; use lazaret_core::identity::{ContainerId, NamespaceIdentity}; use lazaret_core::records::VolumeAccess; use oci_spec::runtime::{ Capabilities, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxIdMappingBuilder, LinuxNamespaceBuilder, LinuxNamespaceType, Mount, MountBuilder, ProcessBuilder, RootBuilder, Spec, SpecBuilder, UserBuilder, }; 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"; #[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, } impl FileIdentity { fn from_metadata(metadata: &Metadata) -> Self { Self { device: metadata.dev(), inode: metadata.ino(), } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct HostTerminal { term: String, color_term: Option, source: PathBuf, identity: FileIdentity, } impl HostTerminal { /// Selects one exact compiled host terminfo entry beneath a trusted root. /// /// # Errors /// Invalid names, inaccessible entries, non-files, and sources outside the roots are refused. pub fn from_trusted_roots( term: impl Into, color_term: Option, source: &Path, trusted_roots: &[PathBuf], ) -> 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", )); } let source = std::fs::canonicalize(source).map_err(|error| { SandboxBuildError::new( SandboxBuildErrorKind::InvalidTerminal, format!("cannot canonicalize terminfo source: {error}"), ) })?; if source.file_name() != Some(OsStr::new(&term)) { return Err(SandboxBuildError::new( SandboxBuildErrorKind::InvalidTerminal, "terminfo source name differs from TERM", )); } let beneath_trusted_root = trusted_roots.iter().any(|root| { std::fs::canonicalize(root) .ok() .is_some_and(|root| source.starts_with(&root) && source != root) }); if !beneath_trusted_root { return Err(SandboxBuildError::new( SandboxBuildErrorKind::InvalidTerminal, "terminfo source is outside every trusted root", )); } let metadata = std::fs::metadata(&source).map_err(|error| { SandboxBuildError::new( SandboxBuildErrorKind::InvalidTerminal, format!("cannot inspect terminfo source: {error}"), ) })?; if !metadata.is_file() { return Err(SandboxBuildError::new( SandboxBuildErrorKind::InvalidTerminal, "terminfo source is not a regular file", )); } Ok(Self { term, color_term, source, identity: FileIdentity::from_metadata(&metadata), }) } fn revalidate(&self) -> Result<(), SandboxBuildError> { let metadata = std::fs::metadata(&self.source).map_err(|error| { SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, format!("cannot revalidate terminfo source: {error}"), ) })?; if !metadata.is_file() || FileIdentity::from_metadata(&metadata) != self.identity { return Err(SandboxBuildError::new( SandboxBuildErrorKind::ChangedAuthority, "terminfo source changed identity", )); } 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) } } pub struct SandboxBuildRequest<'a> { pub configuration: &'a ResolvedDomainConfiguration, pub container_id: ContainerId, pub expected_network_namespace: NamespaceIdentity, pub root_process: RootProcess, pub terminal: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct RunscCommand { program: OsString, arguments: Vec, } impl RunscCommand { #[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, } 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 } } /// 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::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 { 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()?; } 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 command = build_runsc_command(&paths); let root = RootBuilder::default() .path(paths.rootfs) .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()) })?; Ok(SandboxBundle { specification, specification_json, command, }) } struct RuntimePaths { container_name: String, bundle: PathBuf, runsc_root: PathBuf, rootfs: PathBuf, control_sources: PathBuf, } fn runtime_paths( configuration: &ResolvedDomainConfiguration, container_id: ContainerId, ) -> Result { 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"), container_name, }) } fn build_mounts( configuration: &ResolvedDomainConfiguration, terminal: Option<&HostTerminal>, control_sources: &Path, ) -> Result, 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( terminal.source.clone(), terminal.destination(), true, true, )?); } Ok(mounts) } fn build_linux() -> Result { 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::, _>>()?; 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) -> RunscCommand { RunscCommand { program: OsString::from("runsc"), arguments: vec![ OsString::from(format!( "-root={}", paths .runsc_root .to_str() .expect("validated runtime path remains UTF-8") )), 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), ], } } fn build_process( configuration: &ResolvedDomainConfiguration, root_process: RootProcess, terminal: Option<&HostTerminal>, ) -> Result { 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::>(); 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, 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::>()) .build() .map_err(oci_error) }) .collect() } fn control_mounts(control_sources: &Path) -> Result, 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 { 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_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 { 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 }