2026-07-19 05:12:57 +09:00
|
|
|
use super::validation::{
|
|
|
|
|
validate_declared_host_path, validate_environment_name, validate_sandbox_path,
|
|
|
|
|
};
|
|
|
|
|
use super::{
|
|
|
|
|
AsRawFd, BTreeMap, BTreeSet, ConfigurationError, ConfigurationErrorKind,
|
|
|
|
|
ConfigurationLoadRequest, ConfinedPathPlan, ConfinedPathType, EnvironmentReference,
|
|
|
|
|
EnvironmentValue, File, FileType, HostPath, Ipv4Prefix, MAX_CONFIGURATION_BYTES, Mode,
|
|
|
|
|
NetworkProbeTargetConfigurationV1, NetworkReadinessConfigurationV1, OFlags, OsStrExt, OsString,
|
|
|
|
|
OwnedFd, Path, PathBuf, ProfileConfigurationV1, Read, ResolveFlags, ResolvedAutoApprovalRule,
|
|
|
|
|
ResolvedDnsProbeTargetV1, ResolvedNetworkProbeTargetV1, ResolvedNetworkReadinessV1,
|
|
|
|
|
ResolvedVolume, Seek, SeekFrom, Stat, TrustedHostConfigurationV1, VolumeAccess, VolumeKind,
|
|
|
|
|
fmt, openat2,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pub(super) fn validate_host_base(path: &Path, name: &str) -> Result<(), ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn config_home(request: &ConfigurationLoadRequest) -> PathBuf {
|
2026-07-19 03:02:44 +09:00
|
|
|
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"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn data_home(request: &ConfigurationLoadRequest) -> PathBuf {
|
2026-07-19 03:02:44 +09:00
|
|
|
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"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn expand_host_path(
|
2026-07-19 03:02:44 +09:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn read_stable_configuration(
|
|
|
|
|
plan: &ConfinedPathPlan,
|
|
|
|
|
) -> Result<Vec<u8>, ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn validate_configuration_stat(stat: &Stat) -> Result<(), ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn confined_existing(
|
2026-07-19 03:02:44 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn open_absolute_directory(path: &Path) -> Result<OwnedFd, ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
openat2(
|
|
|
|
|
rustix::fs::CWD,
|
|
|
|
|
path,
|
|
|
|
|
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
|
|
|
|
Mode::empty(),
|
|
|
|
|
ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
|
|
|
|
|
)
|
|
|
|
|
.map_err(io_error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn ensure_owned_directory(path: &Path, uid: u32) -> Result<PathBuf, ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn validate_persistent_destinations(
|
2026-07-19 03:02:44 +09:00
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn require_clear_of_protected(
|
2026-07-19 03:02:44 +09:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn require_strict_child_of_one(
|
2026-07-19 03:02:44 +09:00
|
|
|
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",
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn require_pairwise_non_ancestral<'a>(
|
2026-07-19 03:02:44 +09:00
|
|
|
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('/'))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn require_pairwise_disjoint(
|
2026-07-19 03:02:44 +09:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn paths_intersect(left: &Path, right: &Path) -> bool {
|
2026-07-19 03:02:44 +09:00
|
|
|
left == right || left.starts_with(right) || right.starts_with(left)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn is_strict_descendant(child: &Path, parent: &Path) -> bool {
|
2026-07-19 03:02:44 +09:00
|
|
|
child != parent && child.starts_with(parent)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn resolve_network(
|
2026-07-19 03:02:44 +09:00
|
|
|
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()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MAX_READINESS_TARGETS: usize = 32;
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn resolve_network_readiness(
|
2026-07-19 03:02:44 +09:00
|
|
|
readiness: &NetworkReadinessConfigurationV1,
|
|
|
|
|
allow: &[Ipv4Prefix],
|
|
|
|
|
deny: &[Ipv4Prefix],
|
|
|
|
|
) -> Result<ResolvedNetworkReadinessV1, ConfigurationError> {
|
|
|
|
|
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::<Result<BTreeSet<_>, 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<BTreeSet<ResolvedNetworkProbeTargetV1>, 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::<Result<BTreeSet<_>, _>>()?;
|
|
|
|
|
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::<BTreeSet<_>>()
|
|
|
|
|
.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::<std::net::Ipv4Addr>().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<String, ConfigurationError> {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn parse_ipv4_prefix(value: &str) -> Result<Ipv4Prefix, ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn resolve_environment_references(
|
2026-07-19 03:02:44 +09:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn resolve_environment_values(
|
2026-07-19 03:02:44 +09:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn validate_executable(
|
|
|
|
|
descriptor: &OwnedFd,
|
|
|
|
|
uid: u32,
|
|
|
|
|
) -> Result<(), ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn encode_volume(volume: &ResolvedVolume) -> Result<Vec<u8>, ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn encode_auto_approval(
|
|
|
|
|
rule: &ResolvedAutoApprovalRule,
|
|
|
|
|
) -> Result<Vec<u8>, ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn push_bytes(target: &mut Vec<u8>, bytes: &[u8]) -> Result<(), ConfigurationError> {
|
2026-07-19 03:02:44 +09:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn push_bool(target: &mut Vec<u8>, value: bool) {
|
2026-07-19 03:02:44 +09:00
|
|
|
target.push(u8::from(value));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn push_sorted_list(
|
2026-07-19 03:02:44 +09:00
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn host_path(path: &Path) -> HostPath {
|
2026-07-19 03:02:44 +09:00
|
|
|
HostPath::new(path.as_os_str().as_bytes().to_vec())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn hex_lower(bytes: &[u8]) -> String {
|
2026-07-19 03:02:44 +09:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 05:12:57 +09:00
|
|
|
pub(super) fn io_error(error: impl fmt::Display) -> ConfigurationError {
|
2026-07-19 03:02:44 +09:00
|
|
|
ConfigurationError::new(ConfigurationErrorKind::Io, error.to_string())
|
|
|
|
|
}
|