2026-07-19 05:12:57 +09:00
|
|
|
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,
|
2026-07-19 22:39:31 +09:00
|
|
|
resolve_network, validate_configuration_stat, validate_executable, validate_host_base,
|
|
|
|
|
validate_persistent_destinations,
|
2026-07-19 05:12:57 +09:00
|
|
|
};
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-19 03:02:44 +09:00
|
|
|
/// 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(),
|
2026-07-22 03:07:42 +09:00
|
|
|
request.sandbox_layout.nested_transient_overrides(),
|
2026-07-19 03:02:44 +09:00
|
|
|
)?;
|
|
|
|
|
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),
|
2026-07-24 23:34:02 +09:00
|
|
|
runsc_dcache: configuration.runtime().runsc_dcache,
|
2026-07-19 03:02:44 +09:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|