lazaret/crates/lazaret-core/src/configuration/loading.rs

386 lines
16 KiB
Rust
Raw Normal View History

use super::confined_io::{
config_home, confined_existing, data_home, ensure_owned_directory, expand_host_path, hex_lower,
host_path, io_error, is_strict_descendant, paths_intersect, read_stable_configuration,
require_clear_of_protected, require_pairwise_disjoint, require_pairwise_non_ancestral,
require_strict_child_of_one, resolve_environment_references, resolve_environment_values,
resolve_network, validate_configuration_stat, validate_executable, validate_host_base,
validate_persistent_destinations,
};
use super::validation::{
validate_profile_name, validate_sandbox_path, validate_structural_configuration, validate_text,
};
use super::{
ConfigurationError, ConfigurationErrorKind, ConfigurationFingerprintRecordV1,
ConfigurationLoadRequest, ConfinedPathPlan, ConfinedPathType, MAX_CONFIGURATION_BYTES, OFlags,
OsStrExt, PathBuf, ResolvedAutoApproval, ResolvedAutoApprovalRule, ResolvedDomainConfiguration,
ResolvedVolume, ResolvedVolumeAuthority, TrustedHostConfigurationV1, VolumeKind,
derive_configuration_fingerprint, derive_domain_id,
};
/// 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(),
request.sandbox_layout.nested_transient_overrides(),
)?;
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,
})
}