1992 lines
67 KiB
Rust
1992 lines
67 KiB
Rust
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<String>) -> 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<PathBuf>,
|
|
pub selected_profile: String,
|
|
pub workspace: PathBuf,
|
|
pub home: PathBuf,
|
|
pub xdg_config_home: Option<PathBuf>,
|
|
pub xdg_data_home: Option<PathBuf>,
|
|
pub xdg_runtime_dir: PathBuf,
|
|
pub environment: BTreeMap<String, OsString>,
|
|
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<String>,
|
|
}
|
|
|
|
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<Item = String>,
|
|
) -> Result<Self, ConfigurationError> {
|
|
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<String> {
|
|
&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<Self, ConfigurationError> {
|
|
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<OwnedFd, ConfigurationError> {
|
|
self.revalidate_ancestor()?;
|
|
open_confined_relative(
|
|
&self.retained_ancestor,
|
|
Path::new(&self.relative_suffix),
|
|
oflags,
|
|
)
|
|
}
|
|
|
|
fn reopen_exact(&self, oflags: OFlags) -> Result<OwnedFd, ConfigurationError> {
|
|
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<OwnedFd, ConfigurationError> {
|
|
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<OwnedFd, ConfigurationError> {
|
|
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<OwnedFd, ConfigurationError> {
|
|
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<DescriptorIdentity, ConfigurationError> {
|
|
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<u8>]) -> 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<OwnedFd, ConfigurationError> {
|
|
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<OwnedFd, ConfigurationError> {
|
|
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<Ipv4Prefix>,
|
|
effective_deny_prefixes: Vec<Ipv4Prefix>,
|
|
ephemeral_environment: BTreeMap<String, EnvironmentValue>,
|
|
configuration_source: ConfinedPathPlan,
|
|
workspace: ConfinedPathPlan,
|
|
storage_root: ConfinedPathPlan,
|
|
runtime_root: ConfinedPathPlan,
|
|
profile_root: ConfinedPathPlan,
|
|
persistent_domain_root: ConfinedPathPlan,
|
|
runtime_domain_root: ConfinedPathPlan,
|
|
volumes: Vec<ResolvedVolumeAuthority>,
|
|
auto_approval_rules: Vec<ResolvedAutoApproval>,
|
|
}
|
|
|
|
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<String, EnvironmentValue> {
|
|
&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<TrustedHostConfigurationV1, ConfigurationError> {
|
|
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<ResolvedDomainConfiguration, ConfigurationError> {
|
|
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<PathBuf> = 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<DomainId, ConfigurationError> {
|
|
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<ConfigurationFingerprint, ConfigurationError> {
|
|
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<PathBuf, ConfigurationError> {
|
|
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<Vec<u8>, 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<Vec<u8>, 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<ConfinedPathPlan, ConfigurationError> {
|
|
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<OwnedFd, ConfigurationError> {
|
|
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<PathBuf, ConfigurationError> {
|
|
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<String>,
|
|
) -> Result<Vec<String>, 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<String>,
|
|
) -> 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<Item = &'a str>,
|
|
kind: ConfigurationErrorKind,
|
|
) -> Result<(), ConfigurationError> {
|
|
let paths = paths.into_iter().collect::<Vec<_>>();
|
|
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<Ipv4Prefix>, Vec<Ipv4Prefix>), 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::<Result<BTreeSet<_>, _>>()?;
|
|
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<Ipv4Prefix, ConfigurationError> {
|
|
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::<std::net::Ipv4Addr>().map_err(|_| {
|
|
ConfigurationError::new(
|
|
ConfigurationErrorKind::InvalidNetwork,
|
|
"network prefix is not IPv4",
|
|
)
|
|
})?;
|
|
let prefix_length = prefix.parse::<u8>().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<Vec<EnvironmentReference>, 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<String, OsString>,
|
|
) -> Result<BTreeMap<String, EnvironmentValue>, 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<Vec<u8>, 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<Vec<u8>, 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<u8>, 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<u8>, value: bool) {
|
|
target.push(u8::from(value));
|
|
}
|
|
|
|
fn push_sorted_list(
|
|
target: &mut Vec<u8>,
|
|
items: impl IntoIterator<Item = Result<Vec<u8>, ConfigurationError>>,
|
|
) -> Result<(), ConfigurationError> {
|
|
let mut items = items.into_iter().collect::<Result<Vec<_>, _>>()?;
|
|
items.sort_unstable();
|
|
push_framed_items(target, &items)
|
|
}
|
|
|
|
fn push_declared_list(
|
|
target: &mut Vec<u8>,
|
|
items: impl IntoIterator<Item = Result<Vec<u8>, ConfigurationError>>,
|
|
) -> Result<(), ConfigurationError> {
|
|
let items = items.into_iter().collect::<Result<Vec<_>, _>>()?;
|
|
push_framed_items(target, &items)
|
|
}
|
|
|
|
fn push_framed_items(target: &mut Vec<u8>, items: &[Vec<u8>]) -> 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());
|
|
}
|
|
}
|