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() { match rustix::fs::mkdirat(¤t, &component, Mode::RWXU) { Ok(()) | Err(rustix::io::Errno::EXIST) => {} Err(error) => return Err(io_error(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())) } const MAX_READINESS_TARGETS: usize = 32; fn resolve_network_readiness( readiness: &NetworkReadinessConfigurationV1, allow: &[Ipv4Prefix], deny: &[Ipv4Prefix], ) -> Result { if readiness.denied.is_empty() || readiness.permitted.is_empty() || readiness.dns.is_empty() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "profile readiness requires denied, permitted, and DNS targets", )); } if readiness.denied.len() > MAX_READINESS_TARGETS || readiness.permitted.len() > MAX_READINESS_TARGETS || readiness.dns.len() > MAX_READINESS_TARGETS { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "profile readiness target count exceeds 32", )); } let denied = resolve_tcp_readiness_targets(&readiness.denied, allow, deny, false)?; let permitted = resolve_tcp_readiness_targets(&readiness.permitted, allow, deny, true)?; let dns = readiness .dns .iter() .map(|target| { Ok(ResolvedDnsProbeTargetV1 { resolver: parse_probe_address(&target.resolver)?, query: normalize_dns_query(&target.query)?, }) }) .collect::, ConfigurationError>>()?; if dns.len() != readiness.dns.len() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "profile readiness contains duplicate DNS targets", )); } Ok(ResolvedNetworkReadinessV1 { denied: denied.into_iter().collect(), permitted: permitted.into_iter().collect(), dns: dns.into_iter().collect(), }) } fn resolve_tcp_readiness_targets( targets: &[NetworkProbeTargetConfigurationV1], allow: &[Ipv4Prefix], deny: &[Ipv4Prefix], expected_allowed: bool, ) -> Result, ConfigurationError> { let resolved = targets .iter() .map(|target| { if target.port == 0 { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "readiness TCP target has port zero", )); } let address = parse_probe_address(&target.address)?; if is_effectively_allowed(address, allow, deny) != expected_allowed { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "readiness TCP target disagrees with effective profile policy", )); } Ok(ResolvedNetworkProbeTargetV1 { address, port: target.port, }) }) .collect::, _>>()?; if resolved.len() != targets.len() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "profile readiness contains duplicate TCP targets", )); } if resolved .iter() .map(|target| target.address) .collect::>() .len() != resolved.len() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "readiness TCP targets repeat an observation address", )); } Ok(resolved) } fn parse_probe_address(value: &str) -> Result<[u8; 4], ConfigurationError> { let address = value.parse::().map_err(|_| { ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "readiness target address is not IPv4", ) })?; if address.is_unspecified() || address.is_multicast() || address.is_broadcast() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "readiness target address is not unicast", )); } Ok(address.octets()) } fn normalize_dns_query(value: &str) -> Result { let query = value.strip_suffix('.').unwrap_or(value); if query.is_empty() || query.len() > 253 || !query.is_ascii() { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "DNS readiness query name is invalid", )); } for label in query.split('.') { if label.is_empty() || label.len() > 63 || label.starts_with('-') || label.ends_with('-') || !label .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') { return Err(ConfigurationError::new( ConfigurationErrorKind::InvalidNetwork, "DNS readiness query name is invalid", )); } } Ok(query.to_ascii_lowercase()) } fn is_effectively_allowed(address: [u8; 4], allow: &[Ipv4Prefix], deny: &[Ipv4Prefix]) -> bool { let best_allow = allow .iter() .filter(|prefix| prefix_contains(**prefix, address)) .map(|prefix| prefix.prefix_length) .max(); let best_deny = deny .iter() .filter(|prefix| prefix_contains(**prefix, address)) .map(|prefix| prefix.prefix_length) .max(); match (best_allow, best_deny) { (Some(allowed), Some(denied)) => allowed > denied, (Some(_) | None, None) => true, (None, Some(_)) => false, } } fn prefix_contains(prefix: Ipv4Prefix, address: [u8; 4]) -> bool { let mask = if prefix.prefix_length == 0 { 0 } else { u32::MAX << (32 - prefix.prefix_length) }; u32::from_be_bytes(address) & mask == u32::from_be_bytes(prefix.address) } 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()) }