308 lines
10 KiB
Rust
308 lines
10 KiB
Rust
|
|
/// 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_sorted_list(
|
||
|
|
&mut preimage,
|
||
|
|
record.network_readiness.denied.iter().map(|target| {
|
||
|
|
Ok([
|
||
|
|
target.address.as_slice(),
|
||
|
|
target.port.to_be_bytes().as_slice(),
|
||
|
|
]
|
||
|
|
.concat())
|
||
|
|
}),
|
||
|
|
)?;
|
||
|
|
push_sorted_list(
|
||
|
|
&mut preimage,
|
||
|
|
record.network_readiness.permitted.iter().map(|target| {
|
||
|
|
Ok([
|
||
|
|
target.address.as_slice(),
|
||
|
|
target.port.to_be_bytes().as_slice(),
|
||
|
|
]
|
||
|
|
.concat())
|
||
|
|
}),
|
||
|
|
)?;
|
||
|
|
push_sorted_list(
|
||
|
|
&mut preimage,
|
||
|
|
record.network_readiness.dns.iter().map(|target| {
|
||
|
|
let mut item = target.resolver.to_vec();
|
||
|
|
push_bytes(&mut item, target.query.as_bytes())?;
|
||
|
|
Ok(item)
|
||
|
|
}),
|
||
|
|
)?;
|
||
|
|
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(())
|
||
|
|
}
|