use std::collections::{BTreeMap, BTreeSet}; use std::ffi::{OsStr, OsString}; use std::fmt; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::os::fd::{AsRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use rustix::fs::{AtFlags, FileType, Mode, OFlags, ResolveFlags, Stat, StatxFlags, openat2, statx}; use sha2::{Digest, Sha256}; use crate::identity::{ConfigurationFingerprint, DeviceId, DomainId, HostPath, InodeId, MountId}; use crate::records::{ ConfigurationFingerprintRecordV1, EnvironmentReference, MailboxRequest, ProfileConfigurationV1, ResolvedAutoApprovalRule, ResolvedVolume, TRUSTED_HOST_CONFIGURATION_SCHEMA_ID, TrustedHostConfigurationV1, VolumeAccess, VolumeKind, }; pub const MAX_CONFIGURATION_BYTES: usize = 1_048_576; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConfigurationErrorKind { InvalidHostInput, InvalidConfigurationSource, InvalidToml, UnknownProfile, InvalidProfile, InvalidPath, PathConflict, InvalidNetwork, MissingEnvironment, InvalidVolume, InvalidAutoApproval, FingerprintOverflow, Io, } #[derive(Debug)] pub struct ConfigurationError { kind: ConfigurationErrorKind, detail: String, } impl ConfigurationError { fn new(kind: ConfigurationErrorKind, detail: impl Into) -> Self { Self { kind, detail: detail.into(), } } #[must_use] pub const fn kind(&self) -> ConfigurationErrorKind { self.kind } } impl fmt::Display for ConfigurationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "{}: {}", error_label(self.kind), self.detail) } } impl std::error::Error for ConfigurationError {} const fn error_label(kind: ConfigurationErrorKind) -> &'static str { match kind { ConfigurationErrorKind::InvalidHostInput => "invalid host input", ConfigurationErrorKind::InvalidConfigurationSource => "invalid configuration source", ConfigurationErrorKind::InvalidToml => "invalid trusted-host configuration", ConfigurationErrorKind::UnknownProfile => "unknown profile", ConfigurationErrorKind::InvalidProfile => "invalid profile", ConfigurationErrorKind::InvalidPath => "invalid path", ConfigurationErrorKind::PathConflict => "path conflict", ConfigurationErrorKind::InvalidNetwork => "invalid network policy", ConfigurationErrorKind::MissingEnvironment => "invalid environment reference", ConfigurationErrorKind::InvalidVolume => "invalid volume", ConfigurationErrorKind::InvalidAutoApproval => "invalid auto-approval rule", ConfigurationErrorKind::FingerprintOverflow => "configuration fingerprint overflow", ConfigurationErrorKind::Io => "host filesystem error", } } pub struct ConfigurationLoadRequest { pub explicit_configuration_path: Option, pub selected_profile: String, pub workspace: PathBuf, pub home: PathBuf, pub xdg_config_home: Option, pub xdg_data_home: Option, pub xdg_runtime_dir: PathBuf, pub environment: BTreeMap, pub sandbox_layout: SandboxLayoutV1, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct Ipv4Prefix { address: [u8; 4], prefix_length: u8, } impl Ipv4Prefix { #[must_use] pub const fn address(self) -> [u8; 4] { self.address } #[must_use] pub const fn prefix_length(self) -> u8 { self.prefix_length } } pub struct SandboxLayoutV1 { protected_destinations: BTreeSet, } impl fmt::Debug for SandboxLayoutV1 { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("SandboxLayoutV1") .field("protected_destinations", &self.protected_destinations) .finish() } } impl SandboxLayoutV1 { /// Builds the closed destination set supplied by production sandbox-layout construction. /// /// # Errors /// Every destination must be a normalized absolute non-root sandbox path and the set must /// include `/workspace`. pub fn from_production_layout( destinations: impl IntoIterator, ) -> Result { let mut protected_destinations = BTreeSet::new(); for destination in destinations { validate_sandbox_path(&destination)?; if !protected_destinations.insert(destination) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "duplicate protected sandbox destination", )); } } if !protected_destinations.contains("/workspace") { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "sandbox layout omits /workspace", )); } Ok(Self { protected_destinations, }) } #[must_use] pub fn protected_destinations(&self) -> &BTreeSet { &self.protected_destinations } } pub struct ConfinedPathPlan { retained_ancestor: OwnedFd, ancestor_device: DeviceId, ancestor_inode: InodeId, ancestor_mount_id: MountId, target_device: DeviceId, target_inode: InodeId, target_mount_id: MountId, relative_suffix: OsString, canonical_label: HostPath, expected_type: ConfinedPathType, } impl fmt::Debug for ConfinedPathPlan { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ConfinedPathPlan") .field("ancestor_device", &self.ancestor_device) .field("ancestor_inode", &self.ancestor_inode) .field("ancestor_mount_id", &self.ancestor_mount_id) .field("target_device", &self.target_device) .field("target_inode", &self.target_inode) .field("target_mount_id", &self.target_mount_id) .field("relative_suffix", &self.relative_suffix) .field("canonical_label", &self.canonical_label) .field("expected_type", &self.expected_type) .finish_non_exhaustive() } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConfinedPathType { Directory, RegularFile, } impl ConfinedPathPlan { fn for_canonical_path( path: &Path, expected_type: ConfinedPathType, ) -> Result { let parent = path.parent().ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "path has no retained parent", ) })?; let suffix = path.file_name().ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "path has no relative suffix", ) })?; let retained_ancestor = open_absolute_directory(parent)?; let ancestor = descriptor_identity(&retained_ancestor)?; let target = open_confined_relative( &retained_ancestor, Path::new(suffix), OFlags::PATH | OFlags::NOFOLLOW, )?; validate_descriptor_type(&target, expected_type)?; let target = descriptor_identity(&target)?; Ok(Self { retained_ancestor, ancestor_device: DeviceId::new(ancestor.device), ancestor_inode: InodeId::new(ancestor.inode), ancestor_mount_id: MountId::new(ancestor.mount_id), target_device: DeviceId::new(target.device), target_inode: InodeId::new(target.inode), target_mount_id: MountId::new(target.mount_id), relative_suffix: suffix.to_os_string(), canonical_label: HostPath::new(path.as_os_str().as_bytes().to_vec()), expected_type, }) } fn reopen_current(&self, oflags: OFlags) -> Result { self.revalidate_ancestor()?; open_confined_relative( &self.retained_ancestor, Path::new(&self.relative_suffix), oflags, ) } fn reopen_exact(&self, oflags: OFlags) -> Result { let descriptor = self.reopen_current(oflags)?; validate_descriptor_type(&descriptor, self.expected_type)?; let identity = descriptor_identity(&descriptor)?; if identity.device != self.target_device.get() || identity.inode != self.target_inode.get() || identity.mount_id != self.target_mount_id.get() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "confined path target changed identity", )); } Ok(descriptor) } /// Reopens the validated target through retained descriptor authority. /// /// # Errors /// Ancestor, target, mount, symlink, magic-link, or expected-type changes are refused. pub fn open_authority(&self) -> Result { self.reopen_exact(OFlags::PATH | OFlags::NOFOLLOW) } /// Opens one normalized descendant without consulting the ambient canonical label. /// /// # Errors /// The plan must name its original directory and the descendant must remain beneath it on /// the same mount without symlinks or magic links and have the expected type. pub fn open_beneath( &self, relative_path: &Path, expected_type: ConfinedPathType, ) -> Result { validate_relative_consumer_path(relative_path)?; if self.expected_type != ConfinedPathType::Directory { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "confined consumer root is not a directory", )); } let root = self.open_authority()?; let descriptor = open_confined_relative(&root, relative_path, OFlags::PATH | OFlags::NOFOLLOW)?; validate_descriptor_type(&descriptor, expected_type)?; Ok(descriptor) } #[must_use] pub const fn target_device(&self) -> DeviceId { self.target_device } #[must_use] pub const fn target_inode(&self) -> InodeId { self.target_inode } #[must_use] pub const fn target_mount_id(&self) -> MountId { self.target_mount_id } fn aliases(&self, other: &Self) -> bool { descriptor_identities_alias( DescriptorIdentity { device: self.target_device.get(), inode: self.target_inode.get(), mount_id: self.target_mount_id.get(), }, DescriptorIdentity { device: other.target_device.get(), inode: other.target_inode.get(), mount_id: other.target_mount_id.get(), }, ) } fn revalidate_ancestor(&self) -> Result<(), ConfigurationError> { let identity = descriptor_identity(&self.retained_ancestor)?; if identity.device != self.ancestor_device.get() || identity.inode != self.ancestor_inode.get() || identity.mount_id != self.ancestor_mount_id.get() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "retained path ancestor changed identity", )); } Ok(()) } #[must_use] pub const fn ancestor_device(&self) -> DeviceId { self.ancestor_device } #[must_use] pub const fn ancestor_inode(&self) -> InodeId { self.ancestor_inode } #[must_use] pub const fn ancestor_mount_id(&self) -> MountId { self.ancestor_mount_id } #[must_use] pub fn relative_suffix(&self) -> &OsStr { &self.relative_suffix } #[must_use] pub const fn canonical_label(&self) -> &HostPath { &self.canonical_label } } fn open_confined_relative( directory: &OwnedFd, relative_path: &Path, oflags: OFlags, ) -> Result { openat2( directory, relative_path, oflags | OFlags::CLOEXEC, Mode::empty(), ResolveFlags::BENEATH | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_XDEV, ) .map_err(io_error) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct DescriptorIdentity { device: u64, inode: u64, mount_id: u64, } fn descriptor_identities_alias(left: DescriptorIdentity, right: DescriptorIdentity) -> bool { left.device == right.device && left.inode == right.inode } fn descriptor_identity(descriptor: &OwnedFd) -> Result { let stat = rustix::fs::fstat(descriptor).map_err(io_error)?; let extended = statx(descriptor, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID).map_err(io_error)?; if !StatxFlags::from_bits_retain(extended.stx_mask).contains(StatxFlags::MNT_ID) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "filesystem does not report mount identity", )); } Ok(DescriptorIdentity { device: stat.st_dev, inode: stat.st_ino, mount_id: extended.stx_mnt_id, }) } fn validate_descriptor_type( descriptor: &OwnedFd, expected_type: ConfinedPathType, ) -> Result<(), ConfigurationError> { let stat = rustix::fs::fstat(descriptor).map_err(io_error)?; let actual = FileType::from_raw_mode(stat.st_mode); let matches = match expected_type { ConfinedPathType::Directory => actual.is_dir(), ConfinedPathType::RegularFile => actual.is_file(), }; if matches { Ok(()) } else { Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "confined path target has the wrong type", )) } } fn validate_relative_consumer_path(path: &Path) -> Result<(), ConfigurationError> { let bytes = path.as_os_str().as_bytes(); if path.is_absolute() || bytes.is_empty() || bytes.contains(&0) || bytes .split(|byte| *byte == b'/') .any(|component| component.is_empty() || component == b"." || component == b"..") { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "consumer path is not a nonempty relative path", )); } if path .components() .any(|component| !matches!(component, std::path::Component::Normal(_))) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "consumer path is not normalized beneath authority", )); } Ok(()) } pub struct EnvironmentValue(OsString); impl fmt::Debug for EnvironmentValue { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("EnvironmentValue([REDACTED])") } } impl EnvironmentValue { #[must_use] pub fn expose(&self) -> &OsStr { &self.0 } } pub struct ResolvedAutoApproval { record: ResolvedAutoApprovalRule, executable_plan: ConfinedPathPlan, invoking_uid: u32, } impl fmt::Debug for ResolvedAutoApproval { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ResolvedAutoApproval") .field("record", &self.record) .field("executable_plan", &self.executable_plan) .finish_non_exhaustive() } } impl ResolvedAutoApproval { #[must_use] pub const fn record(&self) -> &ResolvedAutoApprovalRule { &self.record } #[must_use] pub fn matches_command_argv(&self, argv: &[Vec]) -> bool { let Some(executable) = argv.first() else { return false; }; if argv .iter() .any(|argument| argument.contains(&0) || std::str::from_utf8(argument).is_err()) || executable.as_slice() != self.record.canonical_executable.as_bytes() { return false; } let requested_args = &argv[1..]; let configured_args = &self.record.args; let prefix_matches = requested_args.len() >= configured_args.len() && requested_args .iter() .zip(configured_args) .all(|(requested, configured)| requested == configured.as_bytes()); if self.record.allow_extra_args { prefix_matches } else { requested_args.len() == configured_args.len() && prefix_matches } } /// Revalidates the exact configured executable and returns its open descriptor when argv /// matches the rule. /// /// # Errors /// The request must be an exact direct-vector match and the executable must retain its /// load-time safety properties. pub fn authorize(&self, request: &MailboxRequest) -> Result { let argv = request.command_argv(); if !self.matches_command_argv(argv) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "request vector does not exactly match auto-approval rule", )); } let executable = self .executable_plan .reopen_current(OFlags::RDONLY | OFlags::NONBLOCK)?; validate_executable(&executable, self.invoking_uid)?; Ok(executable) } } pub struct ResolvedVolumeAuthority { record: ResolvedVolume, source_plan: ConfinedPathPlan, } impl fmt::Debug for ResolvedVolumeAuthority { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ResolvedVolumeAuthority") .field("record", &self.record) .field("source_plan", &self.source_plan) .finish() } } impl ResolvedVolumeAuthority { #[must_use] pub const fn record(&self) -> &ResolvedVolume { &self.record } #[must_use] pub const fn source_plan(&self) -> &ConfinedPathPlan { &self.source_plan } /// Reopens and revalidates the declared source through retained authority. /// /// # Errors /// Replacement, symlink, mount, special-file, or declared-kind mismatch is refused. pub fn open_source(&self) -> Result { let descriptor = self.source_plan.open_authority()?; let stat = rustix::fs::fstat(&descriptor).map_err(io_error)?; let kind = FileType::from_raw_mode(stat.st_mode); let matches = match self.record.kind { VolumeKind::File => kind.is_file(), VolumeKind::Directory => kind.is_dir(), }; if !matches { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidVolume, "volume source changed its declared kind", )); } Ok(descriptor) } } pub struct ResolvedDomainConfiguration { selected_profile: String, domain_id: DomainId, configuration_fingerprint: ConfigurationFingerprint, fingerprint_record: ConfigurationFingerprintRecordV1, effective_allow_prefixes: Vec, effective_deny_prefixes: Vec, ephemeral_environment: BTreeMap, configuration_source: ConfinedPathPlan, workspace: ConfinedPathPlan, storage_root: ConfinedPathPlan, runtime_root: ConfinedPathPlan, profile_root: ConfinedPathPlan, persistent_domain_root: ConfinedPathPlan, runtime_domain_root: ConfinedPathPlan, volumes: Vec, auto_approval_rules: Vec, } impl fmt::Debug for ResolvedDomainConfiguration { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ResolvedDomainConfiguration") .field("selected_profile", &self.selected_profile) .field("domain_id", &self.domain_id) .field("configuration_fingerprint", &self.configuration_fingerprint) .field("fingerprint_record", &self.fingerprint_record) .field("effective_allow_prefixes", &self.effective_allow_prefixes) .field("effective_deny_prefixes", &self.effective_deny_prefixes) .field("ephemeral_environment", &self.ephemeral_environment) .field("volumes", &self.volumes) .field("auto_approval_rules", &self.auto_approval_rules) .finish_non_exhaustive() } } impl ResolvedDomainConfiguration { #[must_use] pub fn selected_profile(&self) -> &str { &self.selected_profile } #[must_use] pub const fn domain_id(&self) -> DomainId { self.domain_id } #[must_use] pub const fn configuration_fingerprint(&self) -> ConfigurationFingerprint { self.configuration_fingerprint } #[must_use] pub const fn fingerprint_record(&self) -> &ConfigurationFingerprintRecordV1 { &self.fingerprint_record } #[must_use] pub fn effective_allow_prefixes(&self) -> &[Ipv4Prefix] { &self.effective_allow_prefixes } #[must_use] pub fn effective_deny_prefixes(&self) -> &[Ipv4Prefix] { &self.effective_deny_prefixes } #[must_use] pub const fn ephemeral_environment(&self) -> &BTreeMap { &self.ephemeral_environment } #[must_use] pub fn volumes(&self) -> &[ResolvedVolumeAuthority] { &self.volumes } #[must_use] pub fn auto_approval_rules(&self) -> &[ResolvedAutoApproval] { &self.auto_approval_rules } #[must_use] pub const fn configuration_source(&self) -> &ConfinedPathPlan { &self.configuration_source } #[must_use] pub const fn workspace(&self) -> &ConfinedPathPlan { &self.workspace } #[must_use] pub const fn storage_root(&self) -> &ConfinedPathPlan { &self.storage_root } #[must_use] pub const fn runtime_root(&self) -> &ConfinedPathPlan { &self.runtime_root } #[must_use] pub const fn profile_root(&self) -> &ConfinedPathPlan { &self.profile_root } #[must_use] pub const fn persistent_domain_root(&self) -> &ConfinedPathPlan { &self.persistent_domain_root } #[must_use] pub const fn runtime_domain_root(&self) -> &ConfinedPathPlan { &self.runtime_domain_root } } /// Parses the closed trusted-host configuration document without resolving host authority. /// /// # Errors /// Unknown, duplicate, aliased, malformed, and unknown-version input is rejected. pub fn parse_trusted_configuration( bytes: &[u8], ) -> Result { if bytes.len() > MAX_CONFIGURATION_BYTES { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidConfigurationSource, "configuration exceeds 1,048,576 bytes", )); } let text = std::str::from_utf8(bytes).map_err(|_| { ConfigurationError::new( ConfigurationErrorKind::InvalidToml, "configuration is not UTF-8", ) })?; let configuration: TrustedHostConfigurationV1 = toml::from_str(text).map_err(|error| { ConfigurationError::new(ConfigurationErrorKind::InvalidToml, error.to_string()) })?; validate_structural_configuration(&configuration)?; Ok(configuration) } /// Resolves one selected profile into move-owned host authority. /// /// # Errors /// Loading fails atomically when source stability, strict syntax, confinement, environment, /// network, volume, executable, or fingerprint validation fails. #[allow(clippy::too_many_lines)] pub fn load_trusted_configuration( request: ConfigurationLoadRequest, ) -> Result { validate_host_base(&request.home, "HOME")?; validate_host_base(&request.xdg_runtime_dir, "XDG_RUNTIME_DIR")?; validate_profile_name(&request.selected_profile)?; let configuration_path = if let Some(explicit) = &request.explicit_configuration_path { let declaration = explicit.as_os_str().to_str().ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidHostInput, "configuration path is not UTF-8", ) })?; expand_host_path(declaration, &request.home, None)? } else { config_home(&request).join("lazaret/config.toml") }; if request.explicit_configuration_path.is_none() { validate_host_base(&configuration_path, "configuration path")?; } let configuration_canonical = std::fs::canonicalize(&configuration_path).map_err(io_error)?; let configuration_source = ConfinedPathPlan::for_canonical_path( &configuration_canonical, ConfinedPathType::RegularFile, )?; let configuration_bytes = read_stable_configuration(&configuration_source)?; let configuration = parse_trusted_configuration(&configuration_bytes)?; let selected = configuration .profiles() .get(&request.selected_profile) .ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::UnknownProfile, request.selected_profile.clone(), ) })?; let workspace_canonical = std::fs::canonicalize(&request.workspace).map_err(io_error)?; let workspace = confined_existing(&workspace_canonical, ConfinedPathType::Directory, false)?; let workspace_bytes = workspace_canonical.as_os_str().as_bytes(); let domain_id = derive_domain_id(&request.selected_profile, workspace_bytes)?; let data_root_declaration = configuration.storage().data_root.as_deref(); let storage_path = match data_root_declaration { Some(declaration) => expand_host_path(declaration, &request.home, None)?, None => data_home(&request).join("lazaret"), }; let runtime_path = request.xdg_runtime_dir.join("lazaret"); let invoking_uid = rustix::process::getuid().as_raw(); let storage_canonical = ensure_owned_directory(&storage_path, invoking_uid)?; let runtime_canonical = ensure_owned_directory(&runtime_path, invoking_uid)?; let profile_path = storage_canonical .join("profiles") .join(&request.selected_profile); let profile_canonical = ensure_owned_directory(&profile_path, invoking_uid)?; let domain_hex = hex_lower(domain_id.as_bytes()); let persistent_domain_canonical = ensure_owned_directory( &storage_canonical.join("domains").join(&domain_hex), invoking_uid, )?; let runtime_domain_canonical = ensure_owned_directory( &runtime_canonical.join("domains").join(&domain_hex), invoking_uid, )?; let storage_root = confined_existing(&storage_canonical, ConfinedPathType::Directory, false)?; let runtime_root = confined_existing(&runtime_canonical, ConfinedPathType::Directory, false)?; let profile_root = confined_existing(&profile_canonical, ConfinedPathType::Directory, false)?; let persistent_domain_root = confined_existing( &persistent_domain_canonical, ConfinedPathType::Directory, false, )?; let runtime_domain_root = confined_existing( &runtime_domain_canonical, ConfinedPathType::Directory, false, )?; let core_paths = [ configuration_canonical.as_path(), workspace_canonical.as_path(), storage_canonical.as_path(), runtime_canonical.as_path(), ]; let core_authorities = [ &configuration_source, &workspace, &storage_root, &runtime_root, ]; require_pairwise_disjoint( &core_paths, &core_authorities, ConfigurationErrorKind::PathConflict, )?; let persistent_dirs = validate_persistent_destinations( &selected.persistent_dirs, request.sandbox_layout.protected_destinations(), )?; let (effective_allow_prefixes, effective_deny_prefixes) = resolve_network(&configuration, selected)?; let environment_references = resolve_environment_references(selected)?; let mut volumes = Vec::with_capacity(selected.volumes.len()); let mut resolved_volume_records = Vec::with_capacity(selected.volumes.len()); let mut volume_paths: Vec = Vec::with_capacity(selected.volumes.len()); for volume in &selected.volumes { validate_sandbox_path(&volume.container)?; require_strict_child_of_one(&volume.container, &persistent_dirs)?; require_clear_of_protected( &volume.container, request.sandbox_layout.protected_destinations(), )?; let under_profile = volume.host.starts_with("{profile_dir}/"); if volume.create && (volume.kind != VolumeKind::Directory || !under_profile) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidVolume, "create=true requires a directory strictly beneath {profile_dir}", )); } let source_path = expand_host_path(&volume.host, &request.home, Some(&profile_canonical))?; let canonical = if volume.create { ensure_owned_directory(&source_path, invoking_uid)? } else { std::fs::canonicalize(&source_path).map_err(io_error)? }; if !volume.create && canonical.as_os_str().as_bytes() != source_path.as_os_str().as_bytes() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidVolume, "volume source contains a symlink", )); } let expected = match volume.kind { VolumeKind::File => ConfinedPathType::RegularFile, VolumeKind::Directory => ConfinedPathType::Directory, }; let source_plan = confined_existing(&canonical, expected, false)?; for (forbidden, forbidden_authority) in core_paths.iter().zip(core_authorities) { if paths_intersect(&canonical, forbidden) { let allowed_profile_child = under_profile && *forbidden == storage_canonical.as_path() && is_strict_descendant(&canonical, &profile_canonical); if !allowed_profile_child { return Err(ConfigurationError::new( ConfigurationErrorKind::PathConflict, "volume source intersects a protected host tree", )); } } if source_plan.aliases(forbidden_authority) { return Err(ConfigurationError::new( ConfigurationErrorKind::PathConflict, "volume source aliases a protected host authority", )); } } if volume_paths .iter() .any(|other| paths_intersect(&canonical, other)) || volumes .iter() .any(|other: &ResolvedVolumeAuthority| source_plan.aliases(&other.source_plan)) { return Err(ConfigurationError::new( ConfigurationErrorKind::PathConflict, "volume sources overlap", )); } let record = ResolvedVolume { canonical_host_source: host_path(&canonical), container_path: volume.container.clone(), kind: volume.kind, access: volume.access, create: volume.create, }; volume_paths.push(canonical); resolved_volume_records.push(record.clone()); volumes.push(ResolvedVolumeAuthority { record, source_plan, }); } require_pairwise_non_ancestral( selected .volumes .iter() .map(|volume| volume.container.as_str()), ConfigurationErrorKind::InvalidVolume, )?; let mut auto_approval_rules = Vec::new(); let mut resolved_auto_approval_records = Vec::new(); if !selected.escalation.enabled && !selected.escalation.auto_approve.is_empty() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "disabled escalation profile contains auto-approval rules", )); } for rule in &selected.escalation.auto_approve { validate_text(&rule.executable, "auto-approval executable")?; for argument in &rule.args { validate_text(argument, "auto-approval argument")?; } if rule.allow_extra_args && rule.args.is_empty() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "allow_extra_args requires a nonempty configured prefix", )); } let executable_path = expand_host_path(&rule.executable, &request.home, None)?; let canonical = std::fs::canonicalize(&executable_path).map_err(io_error)?; if core_paths .iter() .copied() .chain(volume_paths.iter().map(PathBuf::as_path)) .chain([ profile_canonical.as_path(), persistent_domain_canonical.as_path(), runtime_domain_canonical.as_path(), ]) .any(|forbidden| paths_intersect(&canonical, forbidden)) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "auto-approval executable intersects a writable or control tree", )); } let executable_plan = confined_existing(&canonical, ConfinedPathType::RegularFile, true)?; if core_authorities .iter() .copied() .chain([&profile_root, &persistent_domain_root, &runtime_domain_root]) .any(|forbidden| executable_plan.aliases(forbidden)) || volumes .iter() .any(|volume| executable_plan.aliases(&volume.source_plan)) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "auto-approval executable aliases a writable or control authority", )); } let executable = executable_plan.reopen_exact(OFlags::RDONLY | OFlags::NONBLOCK)?; validate_executable(&executable, invoking_uid)?; let record = ResolvedAutoApprovalRule { canonical_executable: host_path(&canonical), args: rule.args.clone(), allow_extra_args: rule.allow_extra_args, }; if resolved_auto_approval_records.contains(&record) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "duplicate normalized auto-approval rule", )); } resolved_auto_approval_records.push(record.clone()); auto_approval_rules.push(ResolvedAutoApproval { record, executable_plan, invoking_uid, }); } let configuration_descriptor = configuration_source.open_authority()?; validate_configuration_stat(&rustix::fs::fstat(&configuration_descriptor).map_err(io_error)?)?; for authority in [ &workspace, &storage_root, &runtime_root, &profile_root, &persistent_domain_root, &runtime_domain_root, ] { drop(authority.open_authority()?); } for volume in &volumes { drop(volume.open_source()?); } for rule in &auto_approval_rules { let executable = rule .executable_plan .reopen_exact(OFlags::RDONLY | OFlags::NONBLOCK)?; validate_executable(&executable, invoking_uid)?; } let fingerprint_record = ConfigurationFingerprintRecordV1 { selected_profile: request.selected_profile.clone(), storage_root: host_path(&storage_canonical), runtime_root: host_path(&runtime_canonical), image: selected.image.clone(), isolate_gitignored: selected.isolate_gitignored, persistent_dirs, environment_references, volumes: resolved_volume_records, effective_allow_prefixes: effective_allow_prefixes .iter() .map(|prefix| (prefix.address, prefix.prefix_length)) .collect(), effective_deny_prefixes: effective_deny_prefixes .iter() .map(|prefix| (prefix.address, prefix.prefix_length)) .collect(), escalation_enabled: selected.escalation.enabled, auto_approval_rules: resolved_auto_approval_records, }; let configuration_fingerprint = derive_configuration_fingerprint(&fingerprint_record)?; let ephemeral_environment = resolve_environment_values( &fingerprint_record.environment_references, &request.environment, )?; Ok(ResolvedDomainConfiguration { selected_profile: request.selected_profile, domain_id, configuration_fingerprint, fingerprint_record, effective_allow_prefixes, effective_deny_prefixes, ephemeral_environment, configuration_source, workspace, storage_root, runtime_root, profile_root, persistent_domain_root, runtime_domain_root, volumes, auto_approval_rules, }) } /// Derives the specification-framed v1 Domain ID. /// /// # Errors /// A field longer than `u32::MAX` cannot be framed. pub fn derive_domain_id( selected_profile: &str, canonical_workspace: &[u8], ) -> Result { let mut preimage = b"lazaret-domain-id-v1\0".to_vec(); push_bytes(&mut preimage, selected_profile.as_bytes())?; push_bytes(&mut preimage, canonical_workspace)?; Ok(DomainId::new(Sha256::digest(preimage).into())) } /// Derives the specification-framed v1 Configuration Fingerprint. /// /// # Errors /// A field or collection longer than `u32::MAX` cannot be framed. pub fn derive_configuration_fingerprint( record: &ConfigurationFingerprintRecordV1, ) -> Result { let mut preimage = b"lazaret-configuration-fingerprint-v1\0".to_vec(); push_bytes(&mut preimage, record.selected_profile.as_bytes())?; push_bytes(&mut preimage, record.storage_root.as_bytes())?; push_bytes(&mut preimage, record.runtime_root.as_bytes())?; push_bytes(&mut preimage, record.image.as_bytes())?; push_bool(&mut preimage, record.isolate_gitignored); push_sorted_list( &mut preimage, record.persistent_dirs.iter().map(|path| { let mut item = Vec::new(); push_bytes(&mut item, path.as_bytes())?; Ok(item) }), )?; push_sorted_list( &mut preimage, record.environment_references.iter().map(|reference| { let mut item = Vec::new(); push_bytes(&mut item, reference.sandbox_name.as_bytes())?; push_bytes(&mut item, reference.host_name.as_bytes())?; Ok(item) }), )?; push_sorted_list(&mut preimage, record.volumes.iter().map(encode_volume))?; push_sorted_list( &mut preimage, record .effective_allow_prefixes .iter() .map(|prefix| Ok([prefix.0.as_slice(), &[prefix.1]].concat())), )?; push_sorted_list( &mut preimage, record .effective_deny_prefixes .iter() .map(|prefix| Ok([prefix.0.as_slice(), &[prefix.1]].concat())), )?; push_bool(&mut preimage, record.escalation_enabled); push_sorted_list( &mut preimage, record.auto_approval_rules.iter().map(encode_auto_approval), )?; Ok(ConfigurationFingerprint::new( Sha256::digest(preimage).into(), )) } fn validate_structural_configuration( configuration: &TrustedHostConfigurationV1, ) -> Result<(), ConfigurationError> { if configuration.schema_id() != TRUSTED_HOST_CONFIGURATION_SCHEMA_ID { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidToml, "unknown configuration schema", )); } for (name, profile) in configuration.profiles() { validate_profile_name(name)?; validate_text(&profile.image, "profile image")?; validate_image_reference(&profile.image)?; require_pairwise_non_ancestral( profile.persistent_dirs.iter().map(String::as_str), ConfigurationErrorKind::InvalidProfile, )?; for persistent in &profile.persistent_dirs { validate_sandbox_path(persistent)?; } for (sandbox_name, reference) in &profile.env { validate_environment_name(sandbox_name)?; let host_name = reference.strip_prefix("env:").ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidProfile, "environment value is not an env: reference", ) })?; validate_environment_name(host_name)?; } for volume in &profile.volumes { validate_declared_host_path(&volume.host, true)?; validate_sandbox_path(&volume.container)?; } if !profile.escalation.enabled && !profile.escalation.auto_approve.is_empty() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "disabled escalation profile contains auto-approval rules", )); } for rule in &profile.escalation.auto_approve { validate_declared_host_path(&rule.executable, false)?; for argument in &rule.args { validate_text(argument, "auto-approval argument")?; } if rule.allow_extra_args && rule.args.is_empty() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "allow_extra_args requires a nonempty configured prefix", )); } } for prefix in configuration .network() .default_allow .iter() .chain(configuration.network().default_deny.iter()) .chain(profile.allow_egress.iter()) .chain(profile.deny_egress.iter()) { parse_ipv4_prefix(prefix)?; } } if let Some(data_root) = &configuration.storage().data_root { validate_declared_host_path(data_root, false)?; } Ok(()) } fn validate_image_reference(reference: &str) -> Result<(), ConfigurationError> { let valid = !reference.is_empty() && reference.is_ascii() && !reference .bytes() .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control()) && !reference.contains("://") && !reference.starts_with(['/', ':', '@']) && !reference.ends_with(['/', ':', '@']) && !reference.contains("//") && reference.split('@').count() <= 2; if valid { Ok(()) } else { Err(ConfigurationError::new( ConfigurationErrorKind::InvalidProfile, "profile image is not a valid v1 OCI reference", )) } } fn validate_profile_name(name: &str) -> Result<(), ConfigurationError> { let bytes = name.as_bytes(); let valid = (1..=64).contains(&bytes.len()) && bytes[0].is_ascii_alphanumeric() && bytes .iter() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) && !matches!(name, "." | ".."); if valid { Ok(()) } else { Err(ConfigurationError::new( ConfigurationErrorKind::InvalidProfile, "profile name violates the v1 grammar", )) } } fn validate_environment_name(name: &str) -> Result<(), ConfigurationError> { let mut bytes = name.bytes(); let valid = bytes .next() .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); if valid { Ok(()) } else { Err(ConfigurationError::new( ConfigurationErrorKind::InvalidProfile, "environment name violates the v1 grammar", )) } } fn validate_text(value: &str, field: &str) -> Result<(), ConfigurationError> { if value.contains('\0') { Err(ConfigurationError::new( ConfigurationErrorKind::InvalidProfile, format!("{field} contains NUL"), )) } else { Ok(()) } } fn validate_declared_host_path( declaration: &str, allow_profile: bool, ) -> Result<(), ConfigurationError> { validate_text(declaration, "host path")?; let suffix = if let Some(suffix) = declaration.strip_prefix('/') { suffix } else if let Some(suffix) = declaration.strip_prefix("~/") { suffix } else if allow_profile { declaration.strip_prefix("{profile_dir}/").ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "host path is not absolute or an allowed anchored form", ) })? } else { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "host path is not absolute or ~/ anchored", )); }; validate_path_suffix(suffix) } fn validate_sandbox_path(path: &str) -> Result<(), ConfigurationError> { validate_text(path, "sandbox path")?; let suffix = path.strip_prefix('/').ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "sandbox path is not absolute", ) })?; validate_path_suffix(suffix) } fn validate_path_suffix(suffix: &str) -> Result<(), ConfigurationError> { if suffix.is_empty() || suffix.ends_with('/') || suffix .split('/') .any(|component| component.is_empty() || matches!(component, "." | "..")) || suffix.bytes().any(|byte| { byte.is_ascii_control() || matches!( byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}' | b'$' | b'~' | b'\\' | b'`' | b'\'' | b'"' | b';' | b'|' | b'&' | b'<' | b'>' | b'(' | b')' | b'!' ) }) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "path is empty, non-normalized, or contains expansion syntax", )); } Ok(()) } fn validate_host_base(path: &Path, name: &str) -> Result<(), ConfigurationError> { if !path.is_absolute() || path.as_os_str().is_empty() || path.as_os_str().as_bytes().contains(&0) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidHostInput, format!("{name} must be a NUL-free absolute path"), )); } Ok(()) } fn config_home(request: &ConfigurationLoadRequest) -> PathBuf { request .xdg_config_home .as_ref() .filter(|path| path.is_absolute() && !path.as_os_str().is_empty()) .cloned() .unwrap_or_else(|| request.home.join(".config")) } fn data_home(request: &ConfigurationLoadRequest) -> PathBuf { request .xdg_data_home .as_ref() .filter(|path| path.is_absolute() && !path.as_os_str().is_empty()) .cloned() .unwrap_or_else(|| request.home.join(".local/share")) } fn expand_host_path( declaration: &str, home: &Path, profile: Option<&Path>, ) -> Result { validate_declared_host_path(declaration, profile.is_some())?; if let Some(suffix) = declaration.strip_prefix("~/") { return Ok(home.join(suffix)); } if let Some(suffix) = declaration.strip_prefix("{profile_dir}/") { return profile.map(|root| root.join(suffix)).ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "profile-relative path is invalid in this field", ) }); } Ok(PathBuf::from(declaration)) } fn read_stable_configuration(plan: &ConfinedPathPlan) -> Result, ConfigurationError> { let path_descriptor = plan.open_authority()?; let before = rustix::fs::fstat(&path_descriptor).map_err(io_error)?; validate_configuration_stat(&before)?; let proc_path = format!("/proc/self/fd/{}", path_descriptor.as_raw_fd()); let read_descriptor = rustix::fs::open( proc_path, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK, Mode::empty(), ) .map_err(io_error)?; let opened = rustix::fs::fstat(&read_descriptor).map_err(io_error)?; if !same_file_snapshot(&before, &opened) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidConfigurationSource, "configuration reopen selected a different object", )); } let size = usize::try_from(before.st_size).map_err(|_| { ConfigurationError::new( ConfigurationErrorKind::InvalidConfigurationSource, "configuration size is negative or unsupported", ) })?; if size > MAX_CONFIGURATION_BYTES { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidConfigurationSource, "configuration exceeds 1,048,576 bytes", )); } let mut file = File::from(read_descriptor); let first = read_exact_snapshot(&mut file, size)?; file.seek(SeekFrom::Start(0)).map_err(io_error)?; let second = read_exact_snapshot(&mut file, size)?; let after = rustix::fs::fstat(&file).map_err(io_error)?; if first != second || !same_file_snapshot(&before, &after) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidConfigurationSource, "configuration changed during its single load attempt", )); } Ok(first) } fn validate_configuration_stat(stat: &Stat) -> Result<(), ConfigurationError> { let uid = rustix::process::getuid().as_raw(); if !FileType::from_raw_mode(stat.st_mode).is_file() || stat.st_nlink != 1 || !matches!(stat.st_uid, 0) && stat.st_uid != uid || stat.st_mode & 0o022 != 0 { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidConfigurationSource, "configuration is not a safe regular file", )); } Ok(()) } fn read_exact_snapshot(file: &mut File, size: usize) -> Result, ConfigurationError> { let mut bytes = vec![0; size]; file.read_exact(&mut bytes).map_err(io_error)?; let mut trailing = [0]; match file.read(&mut trailing) { Ok(0) => Ok(bytes), Ok(_) => Err(ConfigurationError::new( ConfigurationErrorKind::InvalidConfigurationSource, "configuration grew during read", )), Err(error) => Err(io_error(error)), } } fn same_file_snapshot(left: &Stat, right: &Stat) -> bool { left.st_dev == right.st_dev && left.st_ino == right.st_ino && left.st_size == right.st_size && left.st_ctime == right.st_ctime && left.st_ctime_nsec == right.st_ctime_nsec } fn confined_existing( canonical: &Path, expected: ConfinedPathType, executable: bool, ) -> Result { let plan = ConfinedPathPlan::for_canonical_path(canonical, expected)?; let descriptor = plan.open_authority()?; let stat = rustix::fs::fstat(&descriptor).map_err(io_error)?; if executable && stat.st_mode & 0o111 == 0 { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "resolved source has the wrong type or mode", )); } Ok(plan) } fn open_absolute_directory(path: &Path) -> Result { openat2( rustix::fs::CWD, path, OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty(), ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS, ) .map_err(io_error) } fn ensure_owned_directory(path: &Path, uid: u32) -> Result { validate_host_base(path, "Lazaret root")?; if path.exists() { let canonical = std::fs::canonicalize(path).map_err(io_error)?; if canonical.as_os_str().as_bytes() != path.as_os_str().as_bytes() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "Lazaret root contains a symlink", )); } validate_owned_directory(&open_absolute_directory(&canonical)?, uid)?; return Ok(canonical); } let mut existing = path; let mut missing = Vec::new(); while !existing.exists() { let name = existing.file_name().ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "cannot find an existing ancestor for Lazaret root", ) })?; missing.push(name.to_os_string()); existing = existing.parent().ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "cannot find an existing ancestor for Lazaret root", ) })?; } let existing_canonical = std::fs::canonicalize(existing).map_err(io_error)?; if existing_canonical.as_os_str().as_bytes() != existing.as_os_str().as_bytes() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "Lazaret root ambient ancestor contains a symlink", )); } let mut current = open_absolute_directory(&existing_canonical)?; let mut canonical = existing_canonical; for component in missing.into_iter().rev() { rustix::fs::mkdirat(¤t, &component, Mode::RWXU).map_err(io_error)?; let next = openat2( ¤t, &component, OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty(), ResolveFlags::BENEATH | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_XDEV, ) .map_err(io_error)?; validate_owned_directory(&next, uid)?; canonical.push(&component); current = next; } Ok(canonical) } fn validate_owned_directory(descriptor: &OwnedFd, uid: u32) -> Result<(), ConfigurationError> { let stat = rustix::fs::fstat(descriptor).map_err(io_error)?; if !FileType::from_raw_mode(stat.st_mode).is_dir() || stat.st_uid != uid || stat.st_mode & 0o7777 != 0o700 { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidPath, "Lazaret-owned directory is not invoking-user-owned mode 0700", )); } Ok(()) } fn validate_persistent_destinations( destinations: &[String], protected: &BTreeSet, ) -> Result, ConfigurationError> { require_pairwise_non_ancestral( destinations.iter().map(String::as_str), ConfigurationErrorKind::InvalidProfile, )?; for destination in destinations { validate_sandbox_path(destination)?; require_clear_of_protected(destination, protected)?; } Ok(destinations.to_vec()) } fn require_clear_of_protected( destination: &str, protected: &BTreeSet, ) -> Result<(), ConfigurationError> { if protected .iter() .any(|protected| sandbox_paths_intersect(destination, protected)) { Err(ConfigurationError::new( ConfigurationErrorKind::PathConflict, "configured sandbox destination intersects a protected destination", )) } else { Ok(()) } } fn require_strict_child_of_one( destination: &str, parents: &[String], ) -> Result<(), ConfigurationError> { let count = parents .iter() .filter(|parent| sandbox_is_strict_descendant(destination, parent)) .count(); if count == 1 { Ok(()) } else { Err(ConfigurationError::new( ConfigurationErrorKind::InvalidVolume, "volume destination is not a strict child of exactly one persistent path", )) } } fn require_pairwise_non_ancestral<'a>( paths: impl IntoIterator, kind: ConfigurationErrorKind, ) -> Result<(), ConfigurationError> { let paths = paths.into_iter().collect::>(); for (index, left) in paths.iter().enumerate() { for right in &paths[index + 1..] { if sandbox_paths_intersect(left, right) { return Err(ConfigurationError::new( kind, "paths are duplicate or ancestral", )); } } } Ok(()) } fn sandbox_paths_intersect(left: &str, right: &str) -> bool { left == right || left .strip_prefix(right) .is_some_and(|suffix| suffix.starts_with('/')) || right .strip_prefix(left) .is_some_and(|suffix| suffix.starts_with('/')) } fn sandbox_is_strict_descendant(child: &str, parent: &str) -> bool { child .strip_prefix(parent) .is_some_and(|suffix| suffix.starts_with('/')) } fn require_pairwise_disjoint( paths: &[&Path], authorities: &[&ConfinedPathPlan], kind: ConfigurationErrorKind, ) -> Result<(), ConfigurationError> { if paths.len() != authorities.len() { return Err(ConfigurationError::new( kind, "host path and authority sets differ", )); } for (index, left) in paths.iter().enumerate() { for (right, right_authority) in paths[index + 1..].iter().zip(&authorities[index + 1..]) { if paths_intersect(left, right) || authorities[index].aliases(right_authority) { return Err(ConfigurationError::new( kind, "host authority trees overlap", )); } } } Ok(()) } fn paths_intersect(left: &Path, right: &Path) -> bool { left == right || left.starts_with(right) || right.starts_with(left) } fn is_strict_descendant(child: &Path, parent: &Path) -> bool { child != parent && child.starts_with(parent) } fn resolve_network( configuration: &TrustedHostConfigurationV1, selected: &ProfileConfigurationV1, ) -> Result<(Vec, Vec), ConfigurationError> { let mut allow = BTreeSet::new(); let mut deny = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] .into_iter() .map(parse_ipv4_prefix) .collect::, _>>()?; for prefix in configuration .network() .default_allow .iter() .chain(&selected.allow_egress) { allow.insert(parse_ipv4_prefix(prefix)?); } for prefix in configuration .network() .default_deny .iter() .chain(&selected.deny_egress) { deny.insert(parse_ipv4_prefix(prefix)?); } if allow.iter().any(|prefix| deny.contains(prefix)) { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "equal normalized prefix is both allowed and denied", )); } Ok((allow.into_iter().collect(), deny.into_iter().collect())) } fn parse_ipv4_prefix(value: &str) -> Result { let (address, prefix) = value.split_once('/').ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "IPv4 prefix has no prefix length", ) })?; if prefix.contains('/') { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "IPv4 prefix contains multiple separators", )); } let address = address.parse::().map_err(|_| { ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "network prefix is not IPv4", ) })?; let prefix_length = prefix.parse::().map_err(|_| { ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "IPv4 prefix length is invalid", ) })?; if prefix_length > 32 { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "IPv4 prefix length exceeds 32", )); } let raw = u32::from(address); let mask = if prefix_length == 0 { 0 } else { u32::MAX << (32 - prefix_length) }; Ok(Ipv4Prefix { address: (raw & mask).to_be_bytes(), prefix_length, }) } fn resolve_environment_references( selected: &ProfileConfigurationV1, ) -> Result, ConfigurationError> { selected .env .iter() .map(|(sandbox_name, declaration)| { let host_name = declaration.strip_prefix("env:").ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::InvalidProfile, "environment value is not an env: reference", ) })?; validate_environment_name(sandbox_name)?; validate_environment_name(host_name)?; Ok(EnvironmentReference { sandbox_name: sandbox_name.clone(), host_name: host_name.to_owned(), }) }) .collect() } fn resolve_environment_values( references: &[EnvironmentReference], environment: &BTreeMap, ) -> Result, ConfigurationError> { references .iter() .map(|reference| { let value = environment.get(&reference.host_name).ok_or_else(|| { ConfigurationError::new( ConfigurationErrorKind::MissingEnvironment, format!("{} is unset", reference.host_name), ) })?; if value.as_bytes().contains(&0) || value.to_str().is_none() { return Err(ConfigurationError::new( ConfigurationErrorKind::MissingEnvironment, format!("{} is not NUL-free UTF-8", reference.host_name), )); } Ok(( reference.sandbox_name.clone(), EnvironmentValue(value.clone()), )) }) .collect() } fn validate_executable(descriptor: &OwnedFd, uid: u32) -> Result<(), ConfigurationError> { let stat = rustix::fs::fstat(descriptor).map_err(io_error)?; let executable_by_invoking_user = if stat.st_uid == uid { stat.st_mode & 0o100 != 0 } else { let primary_gid = rustix::process::getgid().as_raw(); let supplementary_gids = rustix::process::getgroups().map_err(io_error)?; stat.st_mode & 0o001 != 0 || stat.st_mode & 0o010 != 0 && (stat.st_gid == primary_gid || supplementary_gids .iter() .any(|group| group.as_raw() == stat.st_gid)) }; if !FileType::from_raw_mode(stat.st_mode).is_file() || stat.st_nlink != 1 || !matches!(stat.st_uid, 0) && stat.st_uid != uid || stat.st_mode & 0o022 != 0 || !executable_by_invoking_user { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidAutoApproval, "executable is not a safe single-link regular executable", )); } Ok(()) } fn encode_volume(volume: &ResolvedVolume) -> Result, ConfigurationError> { let mut encoded = Vec::new(); push_bytes(&mut encoded, volume.canonical_host_source.as_bytes())?; push_bytes(&mut encoded, volume.container_path.as_bytes())?; encoded.push(match volume.kind { VolumeKind::File => 0, VolumeKind::Directory => 1, }); encoded.push(match volume.access { VolumeAccess::ReadOnly => 0, VolumeAccess::ReadWrite => 1, }); push_bool(&mut encoded, volume.create); Ok(encoded) } fn encode_auto_approval(rule: &ResolvedAutoApprovalRule) -> Result, ConfigurationError> { let mut encoded = Vec::new(); push_bytes(&mut encoded, rule.canonical_executable.as_bytes())?; push_declared_list( &mut encoded, rule.args.iter().map(|argument| { let mut item = Vec::new(); push_bytes(&mut item, argument.as_bytes())?; Ok(item) }), )?; push_bool(&mut encoded, rule.allow_extra_args); Ok(encoded) } fn push_bytes(target: &mut Vec, bytes: &[u8]) -> Result<(), ConfigurationError> { let length = u32::try_from(bytes.len()).map_err(|_| fingerprint_overflow())?; target.extend_from_slice(&length.to_be_bytes()); target.extend_from_slice(bytes); Ok(()) } fn push_bool(target: &mut Vec, value: bool) { target.push(u8::from(value)); } fn push_sorted_list( target: &mut Vec, items: impl IntoIterator, ConfigurationError>>, ) -> Result<(), ConfigurationError> { let mut items = items.into_iter().collect::, _>>()?; items.sort_unstable(); push_framed_items(target, &items) } fn push_declared_list( target: &mut Vec, items: impl IntoIterator, ConfigurationError>>, ) -> Result<(), ConfigurationError> { let items = items.into_iter().collect::, _>>()?; push_framed_items(target, &items) } fn push_framed_items(target: &mut Vec, items: &[Vec]) -> Result<(), ConfigurationError> { let count = u32::try_from(items.len()).map_err(|_| fingerprint_overflow())?; target.extend_from_slice(&count.to_be_bytes()); for item in items { push_bytes(target, item)?; } Ok(()) } fn fingerprint_overflow() -> ConfigurationError { ConfigurationError::new( ConfigurationErrorKind::FingerprintOverflow, "field or collection does not fit v1 u32 framing", ) } fn host_path(path: &Path) -> HostPath { HostPath::new(path.as_os_str().as_bytes().to_vec()) } fn hex_lower(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut output = String::with_capacity(bytes.len() * 2); for byte in bytes { output.push(char::from(HEX[usize::from(byte >> 4)])); output.push(char::from(HEX[usize::from(byte & 0x0f)])); } output } fn io_error(error: impl fmt::Display) -> ConfigurationError { ConfigurationError::new(ConfigurationErrorKind::Io, error.to_string()) } #[cfg(test)] mod tests { use super::{DescriptorIdentity, descriptor_identities_alias, validate_path_suffix}; #[test] fn bind_mount_identity_aliases_across_distinct_mount_ids() { let original = DescriptorIdentity { device: 7, inode: 11, mount_id: 13, }; let bind_alias = DescriptorIdentity { mount_id: 17, ..original }; let distinct = DescriptorIdentity { inode: 19, ..original }; assert!(descriptor_identities_alias(original, bind_alias)); assert!(!descriptor_identities_alias(original, distinct)); } #[test] fn path_suffix_rejects_every_expansion_and_shell_metacharacter() { for metacharacter in [ '*', '?', '[', ']', '{', '}', '$', '~', '\\', '`', '\'', '"', ';', '|', '&', '<', '>', '(', ')', '!', '\n', '\r', '\t', ] { assert!( validate_path_suffix(&format!("safe{metacharacter}suffix")).is_err(), "{metacharacter:?} must be rejected" ); } assert!(validate_path_suffix("safe path/with+literal@characters").is_ok()); } }