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

788 lines
27 KiB
Rust

use super::validation::{
validate_declared_host_path, validate_environment_name, validate_sandbox_path,
};
use super::{
AsRawFd, BTreeMap, BTreeSet, ConfigurationError, ConfigurationErrorKind,
ConfigurationLoadRequest, ConfinedPathPlan, ConfinedPathType, EnvironmentReference,
EnvironmentValue, File, FileType, HostPath, Ipv4Prefix, MAX_CONFIGURATION_BYTES, Mode,
NetworkProbeTargetConfigurationV1, NetworkReadinessConfigurationV1, OFlags, OsStrExt, OsString,
OwnedFd, Path, PathBuf, ProfileConfigurationV1, Read, ResolveFlags, ResolvedAutoApprovalRule,
ResolvedDnsProbeTargetV1, ResolvedNetworkProbeTargetV1, ResolvedNetworkReadinessV1,
ResolvedVolume, Seek, SeekFrom, Stat, TrustedHostConfigurationV1, VolumeAccess, VolumeKind,
fmt, openat2,
};
pub(super) fn validate_host_base(path: &Path, name: &str) -> Result<(), ConfigurationError> {
if !path.is_absolute()
|| path.as_os_str().is_empty()
|| path.as_os_str().as_bytes().contains(&0)
{
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidHostInput,
format!("{name} must be a NUL-free absolute path"),
));
}
Ok(())
}
pub(super) fn config_home(request: &ConfigurationLoadRequest) -> PathBuf {
request
.xdg_config_home
.as_ref()
.filter(|path| path.is_absolute() && !path.as_os_str().is_empty())
.cloned()
.unwrap_or_else(|| request.home.join(".config"))
}
pub(super) fn data_home(request: &ConfigurationLoadRequest) -> PathBuf {
request
.xdg_data_home
.as_ref()
.filter(|path| path.is_absolute() && !path.as_os_str().is_empty())
.cloned()
.unwrap_or_else(|| request.home.join(".local/share"))
}
pub(super) fn expand_host_path(
declaration: &str,
home: &Path,
profile: Option<&Path>,
) -> Result<PathBuf, ConfigurationError> {
validate_declared_host_path(declaration, profile.is_some())?;
if let Some(suffix) = declaration.strip_prefix("~/") {
return Ok(home.join(suffix));
}
if let Some(suffix) = declaration.strip_prefix("{profile_dir}/") {
return profile.map(|root| root.join(suffix)).ok_or_else(|| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
"profile-relative path is invalid in this field",
)
});
}
Ok(PathBuf::from(declaration))
}
pub(super) fn read_stable_configuration(
plan: &ConfinedPathPlan,
) -> Result<Vec<u8>, ConfigurationError> {
let path_descriptor = plan.open_authority()?;
let before = rustix::fs::fstat(&path_descriptor).map_err(io_error)?;
validate_configuration_stat(&before)?;
let proc_path = format!("/proc/self/fd/{}", path_descriptor.as_raw_fd());
let read_descriptor = rustix::fs::open(
proc_path,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
)
.map_err(io_error)?;
let opened = rustix::fs::fstat(&read_descriptor).map_err(io_error)?;
if !same_file_snapshot(&before, &opened) {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidConfigurationSource,
"configuration reopen selected a different object",
));
}
let size = usize::try_from(before.st_size).map_err(|_| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidConfigurationSource,
"configuration size is negative or unsupported",
)
})?;
if size > MAX_CONFIGURATION_BYTES {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidConfigurationSource,
"configuration exceeds 1,048,576 bytes",
));
}
let mut file = File::from(read_descriptor);
let first = read_exact_snapshot(&mut file, size)?;
file.seek(SeekFrom::Start(0)).map_err(io_error)?;
let second = read_exact_snapshot(&mut file, size)?;
let after = rustix::fs::fstat(&file).map_err(io_error)?;
if first != second || !same_file_snapshot(&before, &after) {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidConfigurationSource,
"configuration changed during its single load attempt",
));
}
Ok(first)
}
pub(super) fn validate_configuration_stat(stat: &Stat) -> Result<(), ConfigurationError> {
let uid = rustix::process::getuid().as_raw();
if !FileType::from_raw_mode(stat.st_mode).is_file()
|| stat.st_nlink != 1
|| !matches!(stat.st_uid, 0) && stat.st_uid != uid
|| stat.st_mode & 0o022 != 0
{
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidConfigurationSource,
"configuration is not a safe regular file",
));
}
Ok(())
}
fn read_exact_snapshot(file: &mut File, size: usize) -> Result<Vec<u8>, ConfigurationError> {
let mut bytes = vec![0; size];
file.read_exact(&mut bytes).map_err(io_error)?;
let mut trailing = [0];
match file.read(&mut trailing) {
Ok(0) => Ok(bytes),
Ok(_) => Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidConfigurationSource,
"configuration grew during read",
)),
Err(error) => Err(io_error(error)),
}
}
fn same_file_snapshot(left: &Stat, right: &Stat) -> bool {
left.st_dev == right.st_dev
&& left.st_ino == right.st_ino
&& left.st_size == right.st_size
&& left.st_ctime == right.st_ctime
&& left.st_ctime_nsec == right.st_ctime_nsec
}
pub(super) fn confined_existing(
canonical: &Path,
expected: ConfinedPathType,
executable: bool,
) -> Result<ConfinedPathPlan, ConfigurationError> {
let plan = ConfinedPathPlan::for_canonical_path(canonical, expected)?;
let descriptor = plan.open_authority()?;
let stat = rustix::fs::fstat(&descriptor).map_err(io_error)?;
if executable && stat.st_mode & 0o111 == 0 {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
"resolved source has the wrong type or mode",
));
}
Ok(plan)
}
pub(super) fn open_absolute_directory(path: &Path) -> Result<OwnedFd, ConfigurationError> {
openat2(
rustix::fs::CWD,
path,
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
)
.map_err(io_error)
}
pub(super) fn ensure_owned_directory(path: &Path, uid: u32) -> Result<PathBuf, ConfigurationError> {
validate_host_base(path, "Lazaret root")?;
if path.exists() {
let canonical = std::fs::canonicalize(path).map_err(io_error)?;
if canonical.as_os_str().as_bytes() != path.as_os_str().as_bytes() {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
"Lazaret root contains a symlink",
));
}
validate_owned_directory(&open_absolute_directory(&canonical)?, uid)?;
return Ok(canonical);
}
let mut existing = path;
let mut missing = Vec::new();
while !existing.exists() {
let name = existing.file_name().ok_or_else(|| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
"cannot find an existing ancestor for Lazaret root",
)
})?;
missing.push(name.to_os_string());
existing = existing.parent().ok_or_else(|| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
"cannot find an existing ancestor for Lazaret root",
)
})?;
}
let existing_canonical = std::fs::canonicalize(existing).map_err(io_error)?;
if existing_canonical.as_os_str().as_bytes() != existing.as_os_str().as_bytes() {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
"Lazaret root ambient ancestor contains a symlink",
));
}
let mut current = open_absolute_directory(&existing_canonical)?;
let mut canonical = existing_canonical;
for component in missing.into_iter().rev() {
match rustix::fs::mkdirat(&current, &component, Mode::RWXU) {
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
Err(error) => return Err(io_error(error)),
}
let next = openat2(
&current,
&component,
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::BENEATH
| ResolveFlags::NO_MAGICLINKS
| ResolveFlags::NO_SYMLINKS
| ResolveFlags::NO_XDEV,
)
.map_err(io_error)?;
validate_owned_directory(&next, uid)?;
canonical.push(&component);
current = next;
}
Ok(canonical)
}
fn validate_owned_directory(descriptor: &OwnedFd, uid: u32) -> Result<(), ConfigurationError> {
let stat = rustix::fs::fstat(descriptor).map_err(io_error)?;
if !FileType::from_raw_mode(stat.st_mode).is_dir()
|| stat.st_uid != uid
|| stat.st_mode & 0o7777 != 0o700
{
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidPath,
"Lazaret-owned directory is not invoking-user-owned mode 0700",
));
}
Ok(())
}
pub(super) fn validate_persistent_destinations(
destinations: &[String],
protected: &BTreeSet<String>,
) -> Result<Vec<String>, ConfigurationError> {
require_pairwise_non_ancestral(
destinations.iter().map(String::as_str),
ConfigurationErrorKind::InvalidProfile,
)?;
for destination in destinations {
validate_sandbox_path(destination)?;
require_clear_of_protected(destination, protected)?;
}
Ok(destinations.to_vec())
}
pub(super) fn require_clear_of_protected(
destination: &str,
protected: &BTreeSet<String>,
) -> Result<(), ConfigurationError> {
if protected
.iter()
.any(|protected| sandbox_paths_intersect(destination, protected))
{
Err(ConfigurationError::new(
ConfigurationErrorKind::PathConflict,
"configured sandbox destination intersects a protected destination",
))
} else {
Ok(())
}
}
pub(super) fn require_strict_child_of_one(
destination: &str,
parents: &[String],
) -> Result<(), ConfigurationError> {
let count = parents
.iter()
.filter(|parent| sandbox_is_strict_descendant(destination, parent))
.count();
if count == 1 {
Ok(())
} else {
Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidVolume,
"volume destination is not a strict child of exactly one persistent path",
))
}
}
pub(super) fn require_pairwise_non_ancestral<'a>(
paths: impl IntoIterator<Item = &'a str>,
kind: ConfigurationErrorKind,
) -> Result<(), ConfigurationError> {
let paths = paths.into_iter().collect::<Vec<_>>();
for (index, left) in paths.iter().enumerate() {
for right in &paths[index + 1..] {
if sandbox_paths_intersect(left, right) {
return Err(ConfigurationError::new(
kind,
"paths are duplicate or ancestral",
));
}
}
}
Ok(())
}
fn sandbox_paths_intersect(left: &str, right: &str) -> bool {
left == right
|| left
.strip_prefix(right)
.is_some_and(|suffix| suffix.starts_with('/'))
|| right
.strip_prefix(left)
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn sandbox_is_strict_descendant(child: &str, parent: &str) -> bool {
child
.strip_prefix(parent)
.is_some_and(|suffix| suffix.starts_with('/'))
}
pub(super) fn require_pairwise_disjoint(
paths: &[&Path],
authorities: &[&ConfinedPathPlan],
kind: ConfigurationErrorKind,
) -> Result<(), ConfigurationError> {
if paths.len() != authorities.len() {
return Err(ConfigurationError::new(
kind,
"host path and authority sets differ",
));
}
for (index, left) in paths.iter().enumerate() {
for (right, right_authority) in paths[index + 1..].iter().zip(&authorities[index + 1..]) {
if paths_intersect(left, right) || authorities[index].aliases(right_authority) {
return Err(ConfigurationError::new(
kind,
"host authority trees overlap",
));
}
}
}
Ok(())
}
pub(super) fn paths_intersect(left: &Path, right: &Path) -> bool {
left == right || left.starts_with(right) || right.starts_with(left)
}
pub(super) fn is_strict_descendant(child: &Path, parent: &Path) -> bool {
child != parent && child.starts_with(parent)
}
pub(super) fn resolve_network(
configuration: &TrustedHostConfigurationV1,
selected: &ProfileConfigurationV1,
) -> Result<(Vec<Ipv4Prefix>, Vec<Ipv4Prefix>), ConfigurationError> {
let mut allow = BTreeSet::new();
let mut deny = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
.into_iter()
.map(parse_ipv4_prefix)
.collect::<Result<BTreeSet<_>, _>>()?;
for prefix in configuration
.network()
.default_allow
.iter()
.chain(&selected.allow_egress)
{
allow.insert(parse_ipv4_prefix(prefix)?);
}
for prefix in configuration
.network()
.default_deny
.iter()
.chain(&selected.deny_egress)
{
deny.insert(parse_ipv4_prefix(prefix)?);
}
if allow.iter().any(|prefix| deny.contains(prefix)) {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"equal normalized prefix is both allowed and denied",
));
}
Ok((allow.into_iter().collect(), deny.into_iter().collect()))
}
const MAX_READINESS_TARGETS: usize = 32;
pub(super) fn resolve_network_readiness(
readiness: &NetworkReadinessConfigurationV1,
allow: &[Ipv4Prefix],
deny: &[Ipv4Prefix],
) -> Result<ResolvedNetworkReadinessV1, ConfigurationError> {
if readiness.denied.is_empty() || readiness.permitted.is_empty() || readiness.dns.is_empty() {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"profile readiness requires denied, permitted, and DNS targets",
));
}
if readiness.denied.len() > MAX_READINESS_TARGETS
|| readiness.permitted.len() > MAX_READINESS_TARGETS
|| readiness.dns.len() > MAX_READINESS_TARGETS
{
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"profile readiness target count exceeds 32",
));
}
let denied = resolve_tcp_readiness_targets(&readiness.denied, allow, deny, false)?;
let permitted = resolve_tcp_readiness_targets(&readiness.permitted, allow, deny, true)?;
let dns = readiness
.dns
.iter()
.map(|target| {
Ok(ResolvedDnsProbeTargetV1 {
resolver: parse_probe_address(&target.resolver)?,
query: normalize_dns_query(&target.query)?,
})
})
.collect::<Result<BTreeSet<_>, ConfigurationError>>()?;
if dns.len() != readiness.dns.len() {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"profile readiness contains duplicate DNS targets",
));
}
Ok(ResolvedNetworkReadinessV1 {
denied: denied.into_iter().collect(),
permitted: permitted.into_iter().collect(),
dns: dns.into_iter().collect(),
})
}
fn resolve_tcp_readiness_targets(
targets: &[NetworkProbeTargetConfigurationV1],
allow: &[Ipv4Prefix],
deny: &[Ipv4Prefix],
expected_allowed: bool,
) -> Result<BTreeSet<ResolvedNetworkProbeTargetV1>, ConfigurationError> {
let resolved = targets
.iter()
.map(|target| {
if target.port == 0 {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"readiness TCP target has port zero",
));
}
let address = parse_probe_address(&target.address)?;
if is_effectively_allowed(address, allow, deny) != expected_allowed {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"readiness TCP target disagrees with effective profile policy",
));
}
Ok(ResolvedNetworkProbeTargetV1 {
address,
port: target.port,
})
})
.collect::<Result<BTreeSet<_>, _>>()?;
if resolved.len() != targets.len() {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"profile readiness contains duplicate TCP targets",
));
}
if resolved
.iter()
.map(|target| target.address)
.collect::<BTreeSet<_>>()
.len()
!= resolved.len()
{
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"readiness TCP targets repeat an observation address",
));
}
Ok(resolved)
}
fn parse_probe_address(value: &str) -> Result<[u8; 4], ConfigurationError> {
let address = value.parse::<std::net::Ipv4Addr>().map_err(|_| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"readiness target address is not IPv4",
)
})?;
if address.is_unspecified() || address.is_multicast() || address.is_broadcast() {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"readiness target address is not unicast",
));
}
Ok(address.octets())
}
fn normalize_dns_query(value: &str) -> Result<String, ConfigurationError> {
let query = value.strip_suffix('.').unwrap_or(value);
if query.is_empty() || query.len() > 253 || !query.is_ascii() {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"DNS readiness query name is invalid",
));
}
for label in query.split('.') {
if label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"DNS readiness query name is invalid",
));
}
}
Ok(query.to_ascii_lowercase())
}
fn is_effectively_allowed(address: [u8; 4], allow: &[Ipv4Prefix], deny: &[Ipv4Prefix]) -> bool {
let best_allow = allow
.iter()
.filter(|prefix| prefix_contains(**prefix, address))
.map(|prefix| prefix.prefix_length)
.max();
let best_deny = deny
.iter()
.filter(|prefix| prefix_contains(**prefix, address))
.map(|prefix| prefix.prefix_length)
.max();
match (best_allow, best_deny) {
(Some(allowed), Some(denied)) => allowed > denied,
(Some(_) | None, None) => true,
(None, Some(_)) => false,
}
}
fn prefix_contains(prefix: Ipv4Prefix, address: [u8; 4]) -> bool {
let mask = if prefix.prefix_length == 0 {
0
} else {
u32::MAX << (32 - prefix.prefix_length)
};
u32::from_be_bytes(address) & mask == u32::from_be_bytes(prefix.address)
}
pub(super) fn parse_ipv4_prefix(value: &str) -> Result<Ipv4Prefix, ConfigurationError> {
let (address, prefix) = value.split_once('/').ok_or_else(|| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"IPv4 prefix has no prefix length",
)
})?;
if prefix.contains('/') {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"IPv4 prefix contains multiple separators",
));
}
let address = address.parse::<std::net::Ipv4Addr>().map_err(|_| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"network prefix is not IPv4",
)
})?;
let prefix_length = prefix.parse::<u8>().map_err(|_| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"IPv4 prefix length is invalid",
)
})?;
if prefix_length > 32 {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidNetwork,
"IPv4 prefix length exceeds 32",
));
}
let raw = u32::from(address);
let mask = if prefix_length == 0 {
0
} else {
u32::MAX << (32 - prefix_length)
};
Ok(Ipv4Prefix {
address: (raw & mask).to_be_bytes(),
prefix_length,
})
}
pub(super) fn resolve_environment_references(
selected: &ProfileConfigurationV1,
) -> Result<Vec<EnvironmentReference>, ConfigurationError> {
selected
.env
.iter()
.map(|(sandbox_name, declaration)| {
let host_name = declaration.strip_prefix("env:").ok_or_else(|| {
ConfigurationError::new(
ConfigurationErrorKind::InvalidProfile,
"environment value is not an env: reference",
)
})?;
validate_environment_name(sandbox_name)?;
validate_environment_name(host_name)?;
Ok(EnvironmentReference {
sandbox_name: sandbox_name.clone(),
host_name: host_name.to_owned(),
})
})
.collect()
}
pub(super) fn resolve_environment_values(
references: &[EnvironmentReference],
environment: &BTreeMap<String, OsString>,
) -> Result<BTreeMap<String, EnvironmentValue>, ConfigurationError> {
references
.iter()
.map(|reference| {
let value = environment.get(&reference.host_name).ok_or_else(|| {
ConfigurationError::new(
ConfigurationErrorKind::MissingEnvironment,
format!("{} is unset", reference.host_name),
)
})?;
if value.as_bytes().contains(&0) || value.to_str().is_none() {
return Err(ConfigurationError::new(
ConfigurationErrorKind::MissingEnvironment,
format!("{} is not NUL-free UTF-8", reference.host_name),
));
}
Ok((
reference.sandbox_name.clone(),
EnvironmentValue(value.clone()),
))
})
.collect()
}
pub(super) fn validate_executable(
descriptor: &OwnedFd,
uid: u32,
) -> Result<(), ConfigurationError> {
let stat = rustix::fs::fstat(descriptor).map_err(io_error)?;
let executable_by_invoking_user = if stat.st_uid == uid {
stat.st_mode & 0o100 != 0
} else {
let primary_gid = rustix::process::getgid().as_raw();
let supplementary_gids = rustix::process::getgroups().map_err(io_error)?;
stat.st_mode & 0o001 != 0
|| stat.st_mode & 0o010 != 0
&& (stat.st_gid == primary_gid
|| supplementary_gids
.iter()
.any(|group| group.as_raw() == stat.st_gid))
};
if !FileType::from_raw_mode(stat.st_mode).is_file()
|| stat.st_nlink != 1
|| !matches!(stat.st_uid, 0) && stat.st_uid != uid
|| stat.st_mode & 0o022 != 0
|| !executable_by_invoking_user
{
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidAutoApproval,
"executable is not a safe single-link regular executable",
));
}
Ok(())
}
pub(super) fn encode_volume(volume: &ResolvedVolume) -> Result<Vec<u8>, ConfigurationError> {
let mut encoded = Vec::new();
push_bytes(&mut encoded, volume.canonical_host_source.as_bytes())?;
push_bytes(&mut encoded, volume.container_path.as_bytes())?;
encoded.push(match volume.kind {
VolumeKind::File => 0,
VolumeKind::Directory => 1,
});
encoded.push(match volume.access {
VolumeAccess::ReadOnly => 0,
VolumeAccess::ReadWrite => 1,
});
push_bool(&mut encoded, volume.create);
Ok(encoded)
}
pub(super) fn encode_auto_approval(
rule: &ResolvedAutoApprovalRule,
) -> Result<Vec<u8>, ConfigurationError> {
let mut encoded = Vec::new();
push_bytes(&mut encoded, rule.canonical_executable.as_bytes())?;
push_declared_list(
&mut encoded,
rule.args.iter().map(|argument| {
let mut item = Vec::new();
push_bytes(&mut item, argument.as_bytes())?;
Ok(item)
}),
)?;
push_bool(&mut encoded, rule.allow_extra_args);
Ok(encoded)
}
pub(super) fn push_bytes(target: &mut Vec<u8>, bytes: &[u8]) -> Result<(), ConfigurationError> {
let length = u32::try_from(bytes.len()).map_err(|_| fingerprint_overflow())?;
target.extend_from_slice(&length.to_be_bytes());
target.extend_from_slice(bytes);
Ok(())
}
pub(super) fn push_bool(target: &mut Vec<u8>, value: bool) {
target.push(u8::from(value));
}
pub(super) fn push_sorted_list(
target: &mut Vec<u8>,
items: impl IntoIterator<Item = Result<Vec<u8>, ConfigurationError>>,
) -> Result<(), ConfigurationError> {
let mut items = items.into_iter().collect::<Result<Vec<_>, _>>()?;
items.sort_unstable();
push_framed_items(target, &items)
}
fn push_declared_list(
target: &mut Vec<u8>,
items: impl IntoIterator<Item = Result<Vec<u8>, ConfigurationError>>,
) -> Result<(), ConfigurationError> {
let items = items.into_iter().collect::<Result<Vec<_>, _>>()?;
push_framed_items(target, &items)
}
fn push_framed_items(target: &mut Vec<u8>, items: &[Vec<u8>]) -> Result<(), ConfigurationError> {
let count = u32::try_from(items.len()).map_err(|_| fingerprint_overflow())?;
target.extend_from_slice(&count.to_be_bytes());
for item in items {
push_bytes(target, item)?;
}
Ok(())
}
fn fingerprint_overflow() -> ConfigurationError {
ConfigurationError::new(
ConfigurationErrorKind::FingerprintOverflow,
"field or collection does not fit v1 u32 framing",
)
}
pub(super) fn host_path(path: &Path) -> HostPath {
HostPath::new(path.as_os_str().as_bytes().to_vec())
}
pub(super) fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
pub(super) fn io_error(error: impl fmt::Display) -> ConfigurationError {
ConfigurationError::new(ConfigurationErrorKind::Io, error.to_string())
}