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, 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 { 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 { 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_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", )); } 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(()) }