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

303 lines
11 KiB
Rust

use super::confined_io::{
encode_auto_approval, encode_volume, parse_ipv4_prefix, push_bool, push_bytes,
push_sorted_list, require_pairwise_non_ancestral,
};
use super::{
ConfigurationError, ConfigurationErrorKind, ConfigurationFingerprint,
ConfigurationFingerprintRecordV1, Digest, DomainId, MAX_RUNSC_DCACHE, Sha256,
TRUSTED_HOST_CONFIGURATION_SCHEMA_ID, TrustedHostConfigurationV1,
};
/// 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())?;
preimage.extend_from_slice(&record.runsc_dcache.to_be_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(),
))
}
pub(super) 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",
));
}
if configuration.runtime().runsc_dcache > MAX_RUNSC_DCACHE {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidRuntime,
format!("runsc_dcache exceeds {MAX_RUNSC_DCACHE} entries"),
));
}
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)?;
if sandbox_name == "LAZARET_SESSION_ID" {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidProfile,
"environment name is reserved by Lazaret",
));
}
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",
))
}
}
pub(super) 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",
))
}
}
pub(super) 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",
))
}
}
pub(super) fn validate_text(value: &str, field: &str) -> Result<(), ConfigurationError> {
if value.contains('\0') {
Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidProfile,
format!("{field} contains NUL"),
))
} else {
Ok(())
}
}
pub(super) 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)
}
pub(super) 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)
}
pub(super) 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(())
}