Decompose oversized Rust files by responsibility
This commit is contained in:
parent
03bfe92ca0
commit
ee9201b057
62 changed files with 18648 additions and 18513 deletions
File diff suppressed because it is too large
Load diff
767
crates/lazaret-core/src/configuration/confined_io.rs
Normal file
767
crates/lazaret-core/src/configuration/confined_io.rs
Normal file
|
|
@ -0,0 +1,767 @@
|
|||
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(())
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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(¤t, &component, Mode::RWXU) {
|
||||
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||
Err(error) => return Err(io_error(error)),
|
||||
}
|
||||
let next = openat2(
|
||||
¤t,
|
||||
&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(())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
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('/'))
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn paths_intersect(left: &Path, right: &Path) -> bool {
|
||||
left == right || left.starts_with(right) || right.starts_with(left)
|
||||
}
|
||||
|
||||
fn is_strict_descendant(child: &Path, parent: &Path) -> bool {
|
||||
child != parent && child.starts_with(parent)
|
||||
}
|
||||
|
||||
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;
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn push_bool(target: &mut Vec<u8>, value: bool) {
|
||||
target.push(u8::from(value));
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
fn host_path(path: &Path) -> HostPath {
|
||||
HostPath::new(path.as_os_str().as_bytes().to_vec())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn io_error(error: impl fmt::Display) -> ConfigurationError {
|
||||
ConfigurationError::new(ConfigurationErrorKind::Io, error.to_string())
|
||||
}
|
||||
372
crates/lazaret-core/src/configuration/loading.rs
Normal file
372
crates/lazaret-core/src/configuration/loading.rs
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
/// 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(),
|
||||
)?;
|
||||
let (effective_allow_prefixes, effective_deny_prefixes) =
|
||||
resolve_network(&configuration, selected)?;
|
||||
let network_readiness = resolve_network_readiness(
|
||||
&selected.readiness,
|
||||
&effective_allow_prefixes,
|
||||
&effective_deny_prefixes,
|
||||
)?;
|
||||
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),
|
||||
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(),
|
||||
network_readiness,
|
||||
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,
|
||||
})
|
||||
}
|
||||
37
crates/lazaret-core/src/configuration/tests.rs
Normal file
37
crates/lazaret-core/src/configuration/tests.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DescriptorIdentity, descriptor_identities_alias, validate_path_suffix};
|
||||
|
||||
#[test]
|
||||
fn bind_mount_identity_aliases_across_distinct_mount_ids() {
|
||||
let original = DescriptorIdentity {
|
||||
device: 7,
|
||||
inode: 11,
|
||||
mount_id: 13,
|
||||
};
|
||||
let bind_alias = DescriptorIdentity {
|
||||
mount_id: 17,
|
||||
..original
|
||||
};
|
||||
let distinct = DescriptorIdentity {
|
||||
inode: 19,
|
||||
..original
|
||||
};
|
||||
assert!(descriptor_identities_alias(original, bind_alias));
|
||||
assert!(!descriptor_identities_alias(original, distinct));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_suffix_rejects_every_expansion_and_shell_metacharacter() {
|
||||
for metacharacter in [
|
||||
'*', '?', '[', ']', '{', '}', '$', '~', '\\', '`', '\'', '"', ';', '|', '&', '<', '>',
|
||||
'(', ')', '!', '\n', '\r', '\t',
|
||||
] {
|
||||
assert!(
|
||||
validate_path_suffix(&format!("safe{metacharacter}suffix")).is_err(),
|
||||
"{metacharacter:?} must be rejected"
|
||||
);
|
||||
}
|
||||
assert!(validate_path_suffix("safe path/with+literal@characters").is_ok());
|
||||
}
|
||||
}
|
||||
308
crates/lazaret-core/src/configuration/validation.rs
Normal file
308
crates/lazaret-core/src/configuration/validation.rs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/// 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(())
|
||||
}
|
||||
|
|
@ -839,682 +839,4 @@ pub struct DomainStore {
|
|||
pub session_terminals: Vec<SessionTerminal>,
|
||||
pub mailboxes: Vec<Mailbox>,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub enum ParticipantOwnership {
|
||||
PreparedLower(PreparedLowerOwnership),
|
||||
StateAnchor(StateAnchorOwnership),
|
||||
GitRouting(GitRoutingOwnership),
|
||||
Network(Box<NetworkOwnership>),
|
||||
Helper(HelperOwnership),
|
||||
Sandbox(SandboxOwnership),
|
||||
Publication(PublicationOwnership),
|
||||
}
|
||||
|
||||
impl ParticipantOwnership {
|
||||
#[must_use]
|
||||
pub const fn role(&self) -> ParticipantRole {
|
||||
match self {
|
||||
Self::PreparedLower(_) => ParticipantRole::PreparedLower,
|
||||
Self::StateAnchor(_) => ParticipantRole::StateAnchor,
|
||||
Self::GitRouting(_) => ParticipantRole::GitRouting,
|
||||
Self::Network(_) => ParticipantRole::Network,
|
||||
Self::Helper(_) => ParticipantRole::Helper,
|
||||
Self::Sandbox(_) => ParticipantRole::Sandbox,
|
||||
Self::Publication(_) => ParticipantRole::Publication,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn empty(role: ParticipantRole) -> Self {
|
||||
match role {
|
||||
ParticipantRole::PreparedLower => Self::PreparedLower(PreparedLowerOwnership::empty()),
|
||||
ParticipantRole::StateAnchor => Self::StateAnchor(StateAnchorOwnership::empty()),
|
||||
ParticipantRole::GitRouting => Self::GitRouting(GitRoutingOwnership::empty()),
|
||||
ParticipantRole::Network => Self::Network(Box::new(NetworkOwnership::empty())),
|
||||
ParticipantRole::Helper => Self::Helper(HelperOwnership::empty()),
|
||||
ParticipantRole::Sandbox => Self::Sandbox(SandboxOwnership::empty()),
|
||||
ParticipantRole::Publication => Self::Publication(PublicationOwnership::empty()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::PreparedLower(ownership) => ownership.is_empty(),
|
||||
Self::StateAnchor(ownership) => ownership.is_empty(),
|
||||
Self::GitRouting(ownership) => ownership.is_empty(),
|
||||
Self::Network(ownership) => ownership.is_empty(),
|
||||
Self::Helper(ownership) => ownership.is_empty(),
|
||||
Self::Sandbox(ownership) => ownership.is_empty(),
|
||||
Self::Publication(ownership) => ownership.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_intent(&self) -> bool {
|
||||
match self {
|
||||
Self::PreparedLower(ownership) => ownership
|
||||
.lease_state
|
||||
.is_none_or(|state| state == ParticipantState::Intent),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_release_intent(&self) -> bool {
|
||||
match self {
|
||||
Self::PreparedLower(ownership) => ownership
|
||||
.lease_state
|
||||
.is_none_or(|state| state == ParticipantState::ReleaseIntent),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active_for(&self, state: ParticipantState, generation_id: GenerationId) -> bool {
|
||||
match (self, state) {
|
||||
(Self::PreparedLower(ownership), ParticipantState::LeaseHeld) => {
|
||||
ownership.is_lease_held()
|
||||
}
|
||||
(Self::StateAnchor(ownership), ParticipantState::Prepared) => ownership.is_prepared(),
|
||||
(Self::GitRouting(ownership), ParticipantState::Prepared) => ownership.is_prepared(),
|
||||
(Self::Network(ownership), ParticipantState::Committed) => {
|
||||
ownership.is_committed(generation_id)
|
||||
}
|
||||
(Self::Helper(ownership), ParticipantState::Committed) => ownership.is_committed(),
|
||||
(Self::Sandbox(ownership), ParticipantState::Committed) => ownership.is_committed(),
|
||||
(Self::Publication(ownership), ParticipantState::Committed) => ownership.is_committed(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleared(&self) -> Self {
|
||||
Self::empty(self.role())
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
match (self, next) {
|
||||
(Self::PreparedLower(old), Self::PreparedLower(new)) => old.validate_progress_to(new),
|
||||
(Self::StateAnchor(old), Self::StateAnchor(new)) => old.validate_progress_to(new),
|
||||
(Self::GitRouting(old), Self::GitRouting(new)) => old.validate_progress_to(new),
|
||||
(Self::Network(old), Self::Network(new)) => old.validate_progress_to(new),
|
||||
(Self::Helper(old), Self::Helper(new)) => old.validate_progress_to(new),
|
||||
(Self::Sandbox(old), Self::Sandbox(new)) => old.validate_progress_to(new),
|
||||
(Self::Publication(old), Self::Publication(new)) => old.validate_progress_to(new),
|
||||
_ => Err(RecordTransitionError::RecordIdentityChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_initial_progress(&self) -> bool {
|
||||
match self {
|
||||
Self::PreparedLower(_) | Self::Publication(_) => true,
|
||||
Self::StateAnchor(ownership) => ownership.daemon.is_initial_acquisition(),
|
||||
Self::GitRouting(ownership) => ownership.daemon.is_initial_acquisition(),
|
||||
Self::Network(ownership) => ownership
|
||||
.runtime_supervisor
|
||||
.is_initial_or_current_committed(),
|
||||
Self::Helper(ownership) => {
|
||||
ownership.connectivity_helper.is_initial_acquisition()
|
||||
&& ownership
|
||||
.runtime_supervisor
|
||||
.is_initial_or_current_committed()
|
||||
}
|
||||
Self::Sandbox(ownership) => {
|
||||
ownership.host_coordinator.is_initial_or_current_committed()
|
||||
&& ownership.runtime_supervisor.is_initial_acquisition()
|
||||
&& ownership.attached_runsc.is_initial_acquisition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_release_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
match (self, next) {
|
||||
(Self::PreparedLower(old), Self::PreparedLower(new)) => old.validate_release_to(new),
|
||||
_ if self == next => Ok(()),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct PreparedLowerOwnership {
|
||||
pub prepared_artifact_id: Option<PreparedArtifactId>,
|
||||
pub source_identity: Option<SourceIdentity>,
|
||||
pub lease_state: Option<ParticipantState>,
|
||||
pub path: Option<HostPath>,
|
||||
pub device: Option<DeviceId>,
|
||||
pub inode: Option<InodeId>,
|
||||
pub released_state_anchor: Option<StateAnchorReleaseEvidence>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct StateAnchorReleaseEvidence {
|
||||
pub daemon: PidfsProcessIdentityV1,
|
||||
pub user_namespace: NamespaceIdentity,
|
||||
pub mount_namespace: NamespaceIdentity,
|
||||
pub mount: MountIdentity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct StateAnchorOwnership {
|
||||
pub lower_artifact_id: Option<PreparedArtifactId>,
|
||||
pub upper_path: Option<HostPath>,
|
||||
pub work_path: Option<HostPath>,
|
||||
pub merged_path: Option<HostPath>,
|
||||
pub daemon: PersistedHostProcessOwnership,
|
||||
pub user_namespace: Option<NamespaceIdentity>,
|
||||
pub mount_namespace: Option<NamespaceIdentity>,
|
||||
pub mount: Option<MountIdentity>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct GitRoutingOwnership {
|
||||
pub mountpoint: Option<HostPath>,
|
||||
pub host_root: Option<HostPath>,
|
||||
pub isolated_root: Option<HostPath>,
|
||||
pub daemon: PersistedHostProcessOwnership,
|
||||
pub mount: Option<MountIdentity>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct NetworkOwnership {
|
||||
pub derived_policy_digest: Option<DerivedPolicyDigest>,
|
||||
pub readiness_fingerprint: Option<ReadinessFingerprint>,
|
||||
pub readiness_evidence: Option<NetworkReadinessEvidence>,
|
||||
pub namespace: Option<NamespaceIdentity>,
|
||||
pub runtime_supervisor: PersistedHostProcessOwnership,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct HelperOwnership {
|
||||
pub network_sidecar_id: Option<NetworkSidecarId>,
|
||||
pub connectivity_helper: PersistedHostProcessOwnership,
|
||||
pub runtime_supervisor: PersistedHostProcessOwnership,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct SandboxOwnership {
|
||||
pub container_id: Option<ContainerId>,
|
||||
pub runtime_root: Option<HostPath>,
|
||||
pub runtime_root_identity: Option<MountIdentity>,
|
||||
pub bundle_digest: Option<BundleDigest>,
|
||||
pub host_coordinator: PersistedHostProcessOwnership,
|
||||
pub runtime_supervisor: PersistedHostProcessOwnership,
|
||||
pub attached_runsc: PersistedHostProcessOwnership,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct PublicationOwnership {
|
||||
pub staged_socket_path: Option<HostPath>,
|
||||
pub staged_identity_path: Option<HostPath>,
|
||||
pub socket_path: Option<HostPath>,
|
||||
pub identity_path: Option<HostPath>,
|
||||
pub socket_identity: Option<MountIdentity>,
|
||||
pub identity_file_identity: Option<MountIdentity>,
|
||||
}
|
||||
|
||||
impl PreparedLowerOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
prepared_artifact_id: None,
|
||||
source_identity: None,
|
||||
lease_state: None,
|
||||
path: None,
|
||||
device: None,
|
||||
inode: None,
|
||||
released_state_anchor: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.prepared_artifact_id.is_none()
|
||||
&& self.source_identity.is_none()
|
||||
&& self.lease_state.is_none()
|
||||
&& self.path.is_none()
|
||||
&& self.device.is_none()
|
||||
&& self.inode.is_none()
|
||||
&& self.released_state_anchor.is_none()
|
||||
}
|
||||
|
||||
fn is_lease_held(&self) -> bool {
|
||||
self.prepared_artifact_id.is_some()
|
||||
&& self.source_identity.is_some()
|
||||
&& self.lease_state == Some(ParticipantState::LeaseHeld)
|
||||
&& self.path.is_some()
|
||||
&& self.device.is_some()
|
||||
&& self.inode.is_some()
|
||||
&& self.released_state_anchor.is_none()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.prepared_artifact_id.as_ref(),
|
||||
next.prepared_artifact_id.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.source_identity.as_ref(), next.source_identity.as_ref())?;
|
||||
validate_prepared_lower_lease_progress(self.lease_state, next.lease_state)?;
|
||||
validate_option_progress(self.path.as_ref(), next.path.as_ref())?;
|
||||
validate_option_progress(self.device.as_ref(), next.device.as_ref())?;
|
||||
validate_option_progress(self.inode.as_ref(), next.inode.as_ref())?;
|
||||
validate_option_progress(
|
||||
self.released_state_anchor.as_ref(),
|
||||
next.released_state_anchor.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_release_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
if self.prepared_artifact_id != next.prepared_artifact_id
|
||||
|| self.source_identity != next.source_identity
|
||||
|| self.path != next.path
|
||||
|| self.device != next.device
|
||||
|| self.inode != next.inode
|
||||
{
|
||||
return Err(RecordTransitionError::EvidenceChanged);
|
||||
}
|
||||
let release_evidence_valid = match self.lease_state {
|
||||
Some(ParticipantState::ReleaseIntent) => {
|
||||
self.released_state_anchor == next.released_state_anchor
|
||||
}
|
||||
_ => next.released_state_anchor.is_some() || self.released_state_anchor.is_none(),
|
||||
};
|
||||
if !release_evidence_valid {
|
||||
return Err(RecordTransitionError::EvidenceChanged);
|
||||
}
|
||||
match (self.lease_state, next.lease_state) {
|
||||
(None, None)
|
||||
| (Some(ParticipantState::ReleaseIntent), Some(ParticipantState::ReleaseIntent)) => {
|
||||
Ok(())
|
||||
}
|
||||
(Some(_), Some(ParticipantState::ReleaseIntent)) => Ok(()),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StateAnchorOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
lower_artifact_id: None,
|
||||
upper_path: None,
|
||||
work_path: None,
|
||||
merged_path: None,
|
||||
daemon: PersistedHostProcessOwnership::unassigned(),
|
||||
user_namespace: None,
|
||||
mount_namespace: None,
|
||||
mount: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.lower_artifact_id.is_none()
|
||||
&& self.upper_path.is_none()
|
||||
&& self.work_path.is_none()
|
||||
&& self.merged_path.is_none()
|
||||
&& self.daemon.is_unassigned()
|
||||
&& self.user_namespace.is_none()
|
||||
&& self.mount_namespace.is_none()
|
||||
&& self.mount.is_none()
|
||||
}
|
||||
|
||||
fn is_prepared(&self) -> bool {
|
||||
self.lower_artifact_id.is_some()
|
||||
&& self.upper_path.is_some()
|
||||
&& self.work_path.is_some()
|
||||
&& self.merged_path.is_some()
|
||||
&& self.daemon.is_current_committed()
|
||||
&& self.user_namespace.is_some()
|
||||
&& self.mount_namespace.is_some()
|
||||
&& self.mount.is_some()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.lower_artifact_id.as_ref(),
|
||||
next.lower_artifact_id.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.upper_path.as_ref(), next.upper_path.as_ref())?;
|
||||
validate_option_progress(self.work_path.as_ref(), next.work_path.as_ref())?;
|
||||
validate_option_progress(self.merged_path.as_ref(), next.merged_path.as_ref())?;
|
||||
validate_process_progress(&self.daemon, &next.daemon)?;
|
||||
validate_option_progress(self.user_namespace.as_ref(), next.user_namespace.as_ref())?;
|
||||
validate_option_progress(self.mount_namespace.as_ref(), next.mount_namespace.as_ref())?;
|
||||
validate_option_progress(self.mount.as_ref(), next.mount.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl GitRoutingOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
mountpoint: None,
|
||||
host_root: None,
|
||||
isolated_root: None,
|
||||
daemon: PersistedHostProcessOwnership::unassigned(),
|
||||
mount: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.mountpoint.is_none()
|
||||
&& self.host_root.is_none()
|
||||
&& self.isolated_root.is_none()
|
||||
&& self.daemon.is_unassigned()
|
||||
&& self.mount.is_none()
|
||||
}
|
||||
|
||||
fn is_prepared(&self) -> bool {
|
||||
self.mountpoint.is_some()
|
||||
&& self.host_root.is_some()
|
||||
&& self.isolated_root.is_some()
|
||||
&& self.daemon.is_current_committed()
|
||||
&& self.mount.is_some()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(self.mountpoint.as_ref(), next.mountpoint.as_ref())?;
|
||||
validate_option_progress(self.host_root.as_ref(), next.host_root.as_ref())?;
|
||||
validate_option_progress(self.isolated_root.as_ref(), next.isolated_root.as_ref())?;
|
||||
validate_process_progress(&self.daemon, &next.daemon)?;
|
||||
validate_option_progress(self.mount.as_ref(), next.mount.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
derived_policy_digest: None,
|
||||
readiness_fingerprint: None,
|
||||
readiness_evidence: None,
|
||||
namespace: None,
|
||||
runtime_supervisor: PersistedHostProcessOwnership::unassigned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.derived_policy_digest.is_none()
|
||||
&& self.readiness_fingerprint.is_none()
|
||||
&& self.readiness_evidence.is_none()
|
||||
&& self.namespace.is_none()
|
||||
&& self.runtime_supervisor.is_unassigned()
|
||||
}
|
||||
|
||||
fn is_committed(&self, generation_id: GenerationId) -> bool {
|
||||
let (
|
||||
Some(derived_policy_digest),
|
||||
Some(readiness_fingerprint),
|
||||
Some(readiness_evidence),
|
||||
Some(namespace),
|
||||
Ok(runtime_supervisor),
|
||||
) = (
|
||||
self.derived_policy_digest,
|
||||
self.readiness_fingerprint,
|
||||
self.readiness_evidence.as_ref(),
|
||||
self.namespace,
|
||||
self.runtime_supervisor.serviceable_pidfs(),
|
||||
)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
readiness_evidence.matches(
|
||||
generation_id,
|
||||
derived_policy_digest,
|
||||
readiness_fingerprint,
|
||||
namespace,
|
||||
runtime_supervisor,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.derived_policy_digest.as_ref(),
|
||||
next.derived_policy_digest.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(
|
||||
self.readiness_fingerprint.as_ref(),
|
||||
next.readiness_fingerprint.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(
|
||||
self.readiness_evidence.as_ref(),
|
||||
next.readiness_evidence.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.namespace.as_ref(), next.namespace.as_ref())?;
|
||||
validate_existing_process_progress(&self.runtime_supervisor, &next.runtime_supervisor)
|
||||
}
|
||||
}
|
||||
|
||||
impl HelperOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
network_sidecar_id: None,
|
||||
connectivity_helper: PersistedHostProcessOwnership::unassigned(),
|
||||
runtime_supervisor: PersistedHostProcessOwnership::unassigned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.network_sidecar_id.is_none()
|
||||
&& self.connectivity_helper.is_unassigned()
|
||||
&& self.runtime_supervisor.is_unassigned()
|
||||
}
|
||||
|
||||
fn is_committed(&self) -> bool {
|
||||
self.network_sidecar_id.is_some()
|
||||
&& self.connectivity_helper.is_current_committed()
|
||||
&& self.runtime_supervisor.is_current_committed()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.network_sidecar_id.as_ref(),
|
||||
next.network_sidecar_id.as_ref(),
|
||||
)?;
|
||||
validate_process_progress(&self.connectivity_helper, &next.connectivity_helper)?;
|
||||
validate_existing_process_progress(&self.runtime_supervisor, &next.runtime_supervisor)
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
container_id: None,
|
||||
runtime_root: None,
|
||||
runtime_root_identity: None,
|
||||
bundle_digest: None,
|
||||
host_coordinator: PersistedHostProcessOwnership::unassigned(),
|
||||
runtime_supervisor: PersistedHostProcessOwnership::unassigned(),
|
||||
attached_runsc: PersistedHostProcessOwnership::unassigned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.container_id.is_none()
|
||||
&& self.runtime_root.is_none()
|
||||
&& self.runtime_root_identity.is_none()
|
||||
&& self.bundle_digest.is_none()
|
||||
&& self.host_coordinator.is_unassigned()
|
||||
&& self.runtime_supervisor.is_unassigned()
|
||||
&& self.attached_runsc.is_unassigned()
|
||||
}
|
||||
|
||||
fn is_committed(&self) -> bool {
|
||||
self.container_id.is_some()
|
||||
&& self.runtime_root.is_some()
|
||||
&& self.runtime_root_identity.is_some()
|
||||
&& self.bundle_digest.is_some()
|
||||
&& self.host_coordinator.is_current_committed()
|
||||
&& self.runtime_supervisor.is_current_committed()
|
||||
&& self.attached_runsc.is_current_committed()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(self.container_id.as_ref(), next.container_id.as_ref())?;
|
||||
validate_option_progress(self.runtime_root.as_ref(), next.runtime_root.as_ref())?;
|
||||
validate_option_progress(
|
||||
self.runtime_root_identity.as_ref(),
|
||||
next.runtime_root_identity.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.bundle_digest.as_ref(), next.bundle_digest.as_ref())?;
|
||||
validate_existing_process_progress(&self.host_coordinator, &next.host_coordinator)?;
|
||||
validate_process_progress(&self.runtime_supervisor, &next.runtime_supervisor)?;
|
||||
validate_process_progress(&self.attached_runsc, &next.attached_runsc)
|
||||
}
|
||||
}
|
||||
|
||||
impl PublicationOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
staged_socket_path: None,
|
||||
staged_identity_path: None,
|
||||
socket_path: None,
|
||||
identity_path: None,
|
||||
socket_identity: None,
|
||||
identity_file_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.staged_socket_path.is_none()
|
||||
&& self.staged_identity_path.is_none()
|
||||
&& self.socket_path.is_none()
|
||||
&& self.identity_path.is_none()
|
||||
&& self.socket_identity.is_none()
|
||||
&& self.identity_file_identity.is_none()
|
||||
}
|
||||
|
||||
fn is_committed(&self) -> bool {
|
||||
self.staged_socket_path.is_some()
|
||||
&& self.staged_identity_path.is_some()
|
||||
&& self.socket_path.is_some()
|
||||
&& self.identity_path.is_some()
|
||||
&& self.socket_identity.is_some()
|
||||
&& self.identity_file_identity.is_some()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.staged_socket_path.as_ref(),
|
||||
next.staged_socket_path.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(
|
||||
self.staged_identity_path.as_ref(),
|
||||
next.staged_identity_path.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.socket_path.as_ref(), next.socket_path.as_ref())?;
|
||||
validate_option_progress(self.identity_path.as_ref(), next.identity_path.as_ref())?;
|
||||
validate_option_progress(self.socket_identity.as_ref(), next.socket_identity.as_ref())?;
|
||||
validate_option_progress(
|
||||
self.identity_file_identity.as_ref(),
|
||||
next.identity_file_identity.as_ref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_participant_progress(
|
||||
old: Option<&ParticipantOwnership>,
|
||||
next: Option<&ParticipantOwnership>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(None, None) => Ok(()),
|
||||
(None, Some(next)) if next.is_initial_progress() => Ok(()),
|
||||
(None, Some(_)) => Err(RecordTransitionError::ProcessStageSkipped),
|
||||
(Some(_), None) => Err(RecordTransitionError::EvidenceLost),
|
||||
(Some(old), Some(next)) => old.validate_progress_to(next),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_participant_release(
|
||||
old: Option<&ParticipantOwnership>,
|
||||
next: Option<&ParticipantOwnership>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(Some(old), Some(next)) => old.validate_release_to(next),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_participant_activation(
|
||||
old: Option<&ParticipantOwnership>,
|
||||
next: Option<&ParticipantOwnership>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(Some(old), Some(next)) => old.validate_progress_to(next),
|
||||
_ => Err(RecordTransitionError::PriorAcquisitionSnapshotRequired),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_prepared_lower_lease_progress(
|
||||
old: Option<ParticipantState>,
|
||||
next: Option<ParticipantState>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(None, None | Some(ParticipantState::Intent))
|
||||
| (
|
||||
Some(ParticipantState::Intent),
|
||||
Some(ParticipantState::Intent | ParticipantState::LeaseHeld),
|
||||
)
|
||||
| (Some(ParticipantState::LeaseHeld), Some(ParticipantState::LeaseHeld)) => Ok(()),
|
||||
(Some(_), None) => Err(RecordTransitionError::EvidenceLost),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_option_progress<T: Eq>(
|
||||
old: Option<&T>,
|
||||
next: Option<&T>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(None, None | Some(_)) => Ok(()),
|
||||
(Some(_), None) => Err(RecordTransitionError::EvidenceLost),
|
||||
(Some(old), Some(next)) if old == next => Ok(()),
|
||||
(Some(_), Some(_)) => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_process_progress(
|
||||
old: &PersistedHostProcessOwnership,
|
||||
next: &PersistedHostProcessOwnership,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(
|
||||
PersistedHostProcessOwnership::Unassigned,
|
||||
PersistedHostProcessOwnership::Unassigned | PersistedHostProcessOwnership::Staged(_),
|
||||
) => Ok(()),
|
||||
(
|
||||
PersistedHostProcessOwnership::Unassigned,
|
||||
PersistedHostProcessOwnership::Committed(_),
|
||||
) => Err(RecordTransitionError::ProcessStageSkipped),
|
||||
(
|
||||
PersistedHostProcessOwnership::Staged(_) | PersistedHostProcessOwnership::Committed(_),
|
||||
PersistedHostProcessOwnership::Unassigned,
|
||||
) => Err(RecordTransitionError::EvidenceLost),
|
||||
(PersistedHostProcessOwnership::Committed(_), PersistedHostProcessOwnership::Staged(_)) => {
|
||||
Err(RecordTransitionError::EvidenceLost)
|
||||
}
|
||||
(
|
||||
PersistedHostProcessOwnership::Staged(old),
|
||||
PersistedHostProcessOwnership::Staged(next),
|
||||
) if old == next => Ok(()),
|
||||
(
|
||||
PersistedHostProcessOwnership::Staged(old),
|
||||
PersistedHostProcessOwnership::Committed(next),
|
||||
) if old == next.pidfs() => Ok(()),
|
||||
(
|
||||
PersistedHostProcessOwnership::Committed(old),
|
||||
PersistedHostProcessOwnership::Committed(next),
|
||||
) if old == next => Ok(()),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_existing_process_progress(
|
||||
old: &PersistedHostProcessOwnership,
|
||||
next: &PersistedHostProcessOwnership,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
if matches!(
|
||||
(old, next),
|
||||
(
|
||||
PersistedHostProcessOwnership::Unassigned,
|
||||
PersistedHostProcessOwnership::Committed(_)
|
||||
)
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
validate_process_progress(old, next)
|
||||
}
|
||||
}
|
||||
include!("lifecycle/ownership.rs");
|
||||
|
|
|
|||
679
crates/lazaret-core/src/records/lifecycle/ownership.rs
Normal file
679
crates/lazaret-core/src/records/lifecycle/ownership.rs
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub enum ParticipantOwnership {
|
||||
PreparedLower(PreparedLowerOwnership),
|
||||
StateAnchor(StateAnchorOwnership),
|
||||
GitRouting(GitRoutingOwnership),
|
||||
Network(Box<NetworkOwnership>),
|
||||
Helper(HelperOwnership),
|
||||
Sandbox(SandboxOwnership),
|
||||
Publication(PublicationOwnership),
|
||||
}
|
||||
|
||||
impl ParticipantOwnership {
|
||||
#[must_use]
|
||||
pub const fn role(&self) -> ParticipantRole {
|
||||
match self {
|
||||
Self::PreparedLower(_) => ParticipantRole::PreparedLower,
|
||||
Self::StateAnchor(_) => ParticipantRole::StateAnchor,
|
||||
Self::GitRouting(_) => ParticipantRole::GitRouting,
|
||||
Self::Network(_) => ParticipantRole::Network,
|
||||
Self::Helper(_) => ParticipantRole::Helper,
|
||||
Self::Sandbox(_) => ParticipantRole::Sandbox,
|
||||
Self::Publication(_) => ParticipantRole::Publication,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn empty(role: ParticipantRole) -> Self {
|
||||
match role {
|
||||
ParticipantRole::PreparedLower => Self::PreparedLower(PreparedLowerOwnership::empty()),
|
||||
ParticipantRole::StateAnchor => Self::StateAnchor(StateAnchorOwnership::empty()),
|
||||
ParticipantRole::GitRouting => Self::GitRouting(GitRoutingOwnership::empty()),
|
||||
ParticipantRole::Network => Self::Network(Box::new(NetworkOwnership::empty())),
|
||||
ParticipantRole::Helper => Self::Helper(HelperOwnership::empty()),
|
||||
ParticipantRole::Sandbox => Self::Sandbox(SandboxOwnership::empty()),
|
||||
ParticipantRole::Publication => Self::Publication(PublicationOwnership::empty()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::PreparedLower(ownership) => ownership.is_empty(),
|
||||
Self::StateAnchor(ownership) => ownership.is_empty(),
|
||||
Self::GitRouting(ownership) => ownership.is_empty(),
|
||||
Self::Network(ownership) => ownership.is_empty(),
|
||||
Self::Helper(ownership) => ownership.is_empty(),
|
||||
Self::Sandbox(ownership) => ownership.is_empty(),
|
||||
Self::Publication(ownership) => ownership.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_intent(&self) -> bool {
|
||||
match self {
|
||||
Self::PreparedLower(ownership) => ownership
|
||||
.lease_state
|
||||
.is_none_or(|state| state == ParticipantState::Intent),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_release_intent(&self) -> bool {
|
||||
match self {
|
||||
Self::PreparedLower(ownership) => ownership
|
||||
.lease_state
|
||||
.is_none_or(|state| state == ParticipantState::ReleaseIntent),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active_for(&self, state: ParticipantState, generation_id: GenerationId) -> bool {
|
||||
match (self, state) {
|
||||
(Self::PreparedLower(ownership), ParticipantState::LeaseHeld) => {
|
||||
ownership.is_lease_held()
|
||||
}
|
||||
(Self::StateAnchor(ownership), ParticipantState::Prepared) => ownership.is_prepared(),
|
||||
(Self::GitRouting(ownership), ParticipantState::Prepared) => ownership.is_prepared(),
|
||||
(Self::Network(ownership), ParticipantState::Committed) => {
|
||||
ownership.is_committed(generation_id)
|
||||
}
|
||||
(Self::Helper(ownership), ParticipantState::Committed) => ownership.is_committed(),
|
||||
(Self::Sandbox(ownership), ParticipantState::Committed) => ownership.is_committed(),
|
||||
(Self::Publication(ownership), ParticipantState::Committed) => ownership.is_committed(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleared(&self) -> Self {
|
||||
Self::empty(self.role())
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
match (self, next) {
|
||||
(Self::PreparedLower(old), Self::PreparedLower(new)) => old.validate_progress_to(new),
|
||||
(Self::StateAnchor(old), Self::StateAnchor(new)) => old.validate_progress_to(new),
|
||||
(Self::GitRouting(old), Self::GitRouting(new)) => old.validate_progress_to(new),
|
||||
(Self::Network(old), Self::Network(new)) => old.validate_progress_to(new),
|
||||
(Self::Helper(old), Self::Helper(new)) => old.validate_progress_to(new),
|
||||
(Self::Sandbox(old), Self::Sandbox(new)) => old.validate_progress_to(new),
|
||||
(Self::Publication(old), Self::Publication(new)) => old.validate_progress_to(new),
|
||||
_ => Err(RecordTransitionError::RecordIdentityChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_initial_progress(&self) -> bool {
|
||||
match self {
|
||||
Self::PreparedLower(_) | Self::Publication(_) => true,
|
||||
Self::StateAnchor(ownership) => ownership.daemon.is_initial_acquisition(),
|
||||
Self::GitRouting(ownership) => ownership.daemon.is_initial_acquisition(),
|
||||
Self::Network(ownership) => ownership
|
||||
.runtime_supervisor
|
||||
.is_initial_or_current_committed(),
|
||||
Self::Helper(ownership) => {
|
||||
ownership.connectivity_helper.is_initial_acquisition()
|
||||
&& ownership
|
||||
.runtime_supervisor
|
||||
.is_initial_or_current_committed()
|
||||
}
|
||||
Self::Sandbox(ownership) => {
|
||||
ownership.host_coordinator.is_initial_or_current_committed()
|
||||
&& ownership.runtime_supervisor.is_initial_acquisition()
|
||||
&& ownership.attached_runsc.is_initial_acquisition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_release_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
match (self, next) {
|
||||
(Self::PreparedLower(old), Self::PreparedLower(new)) => old.validate_release_to(new),
|
||||
_ if self == next => Ok(()),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct PreparedLowerOwnership {
|
||||
pub prepared_artifact_id: Option<PreparedArtifactId>,
|
||||
pub source_identity: Option<SourceIdentity>,
|
||||
pub lease_state: Option<ParticipantState>,
|
||||
pub path: Option<HostPath>,
|
||||
pub device: Option<DeviceId>,
|
||||
pub inode: Option<InodeId>,
|
||||
pub released_state_anchor: Option<StateAnchorReleaseEvidence>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct StateAnchorReleaseEvidence {
|
||||
pub daemon: PidfsProcessIdentityV1,
|
||||
pub user_namespace: NamespaceIdentity,
|
||||
pub mount_namespace: NamespaceIdentity,
|
||||
pub mount: MountIdentity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct StateAnchorOwnership {
|
||||
pub lower_artifact_id: Option<PreparedArtifactId>,
|
||||
pub upper_path: Option<HostPath>,
|
||||
pub work_path: Option<HostPath>,
|
||||
pub merged_path: Option<HostPath>,
|
||||
pub daemon: PersistedHostProcessOwnership,
|
||||
pub user_namespace: Option<NamespaceIdentity>,
|
||||
pub mount_namespace: Option<NamespaceIdentity>,
|
||||
pub mount: Option<MountIdentity>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct GitRoutingOwnership {
|
||||
pub mountpoint: Option<HostPath>,
|
||||
pub host_root: Option<HostPath>,
|
||||
pub isolated_root: Option<HostPath>,
|
||||
pub daemon: PersistedHostProcessOwnership,
|
||||
pub mount: Option<MountIdentity>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct NetworkOwnership {
|
||||
pub derived_policy_digest: Option<DerivedPolicyDigest>,
|
||||
pub readiness_fingerprint: Option<ReadinessFingerprint>,
|
||||
pub readiness_evidence: Option<NetworkReadinessEvidence>,
|
||||
pub namespace: Option<NamespaceIdentity>,
|
||||
pub runtime_supervisor: PersistedHostProcessOwnership,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct HelperOwnership {
|
||||
pub network_sidecar_id: Option<NetworkSidecarId>,
|
||||
pub connectivity_helper: PersistedHostProcessOwnership,
|
||||
pub runtime_supervisor: PersistedHostProcessOwnership,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct SandboxOwnership {
|
||||
pub container_id: Option<ContainerId>,
|
||||
pub runtime_root: Option<HostPath>,
|
||||
pub runtime_root_identity: Option<MountIdentity>,
|
||||
pub bundle_digest: Option<BundleDigest>,
|
||||
pub host_coordinator: PersistedHostProcessOwnership,
|
||||
pub runtime_supervisor: PersistedHostProcessOwnership,
|
||||
pub attached_runsc: PersistedHostProcessOwnership,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct PublicationOwnership {
|
||||
pub staged_socket_path: Option<HostPath>,
|
||||
pub staged_identity_path: Option<HostPath>,
|
||||
pub socket_path: Option<HostPath>,
|
||||
pub identity_path: Option<HostPath>,
|
||||
pub socket_identity: Option<MountIdentity>,
|
||||
pub identity_file_identity: Option<MountIdentity>,
|
||||
}
|
||||
|
||||
impl PreparedLowerOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
prepared_artifact_id: None,
|
||||
source_identity: None,
|
||||
lease_state: None,
|
||||
path: None,
|
||||
device: None,
|
||||
inode: None,
|
||||
released_state_anchor: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.prepared_artifact_id.is_none()
|
||||
&& self.source_identity.is_none()
|
||||
&& self.lease_state.is_none()
|
||||
&& self.path.is_none()
|
||||
&& self.device.is_none()
|
||||
&& self.inode.is_none()
|
||||
&& self.released_state_anchor.is_none()
|
||||
}
|
||||
|
||||
fn is_lease_held(&self) -> bool {
|
||||
self.prepared_artifact_id.is_some()
|
||||
&& self.source_identity.is_some()
|
||||
&& self.lease_state == Some(ParticipantState::LeaseHeld)
|
||||
&& self.path.is_some()
|
||||
&& self.device.is_some()
|
||||
&& self.inode.is_some()
|
||||
&& self.released_state_anchor.is_none()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.prepared_artifact_id.as_ref(),
|
||||
next.prepared_artifact_id.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.source_identity.as_ref(), next.source_identity.as_ref())?;
|
||||
validate_prepared_lower_lease_progress(self.lease_state, next.lease_state)?;
|
||||
validate_option_progress(self.path.as_ref(), next.path.as_ref())?;
|
||||
validate_option_progress(self.device.as_ref(), next.device.as_ref())?;
|
||||
validate_option_progress(self.inode.as_ref(), next.inode.as_ref())?;
|
||||
validate_option_progress(
|
||||
self.released_state_anchor.as_ref(),
|
||||
next.released_state_anchor.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_release_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
if self.prepared_artifact_id != next.prepared_artifact_id
|
||||
|| self.source_identity != next.source_identity
|
||||
|| self.path != next.path
|
||||
|| self.device != next.device
|
||||
|| self.inode != next.inode
|
||||
{
|
||||
return Err(RecordTransitionError::EvidenceChanged);
|
||||
}
|
||||
let release_evidence_valid = match self.lease_state {
|
||||
Some(ParticipantState::ReleaseIntent) => {
|
||||
self.released_state_anchor == next.released_state_anchor
|
||||
}
|
||||
_ => next.released_state_anchor.is_some() || self.released_state_anchor.is_none(),
|
||||
};
|
||||
if !release_evidence_valid {
|
||||
return Err(RecordTransitionError::EvidenceChanged);
|
||||
}
|
||||
match (self.lease_state, next.lease_state) {
|
||||
(None, None)
|
||||
| (Some(ParticipantState::ReleaseIntent), Some(ParticipantState::ReleaseIntent)) => {
|
||||
Ok(())
|
||||
}
|
||||
(Some(_), Some(ParticipantState::ReleaseIntent)) => Ok(()),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StateAnchorOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
lower_artifact_id: None,
|
||||
upper_path: None,
|
||||
work_path: None,
|
||||
merged_path: None,
|
||||
daemon: PersistedHostProcessOwnership::unassigned(),
|
||||
user_namespace: None,
|
||||
mount_namespace: None,
|
||||
mount: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.lower_artifact_id.is_none()
|
||||
&& self.upper_path.is_none()
|
||||
&& self.work_path.is_none()
|
||||
&& self.merged_path.is_none()
|
||||
&& self.daemon.is_unassigned()
|
||||
&& self.user_namespace.is_none()
|
||||
&& self.mount_namespace.is_none()
|
||||
&& self.mount.is_none()
|
||||
}
|
||||
|
||||
fn is_prepared(&self) -> bool {
|
||||
self.lower_artifact_id.is_some()
|
||||
&& self.upper_path.is_some()
|
||||
&& self.work_path.is_some()
|
||||
&& self.merged_path.is_some()
|
||||
&& self.daemon.is_current_committed()
|
||||
&& self.user_namespace.is_some()
|
||||
&& self.mount_namespace.is_some()
|
||||
&& self.mount.is_some()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.lower_artifact_id.as_ref(),
|
||||
next.lower_artifact_id.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.upper_path.as_ref(), next.upper_path.as_ref())?;
|
||||
validate_option_progress(self.work_path.as_ref(), next.work_path.as_ref())?;
|
||||
validate_option_progress(self.merged_path.as_ref(), next.merged_path.as_ref())?;
|
||||
validate_process_progress(&self.daemon, &next.daemon)?;
|
||||
validate_option_progress(self.user_namespace.as_ref(), next.user_namespace.as_ref())?;
|
||||
validate_option_progress(self.mount_namespace.as_ref(), next.mount_namespace.as_ref())?;
|
||||
validate_option_progress(self.mount.as_ref(), next.mount.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl GitRoutingOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
mountpoint: None,
|
||||
host_root: None,
|
||||
isolated_root: None,
|
||||
daemon: PersistedHostProcessOwnership::unassigned(),
|
||||
mount: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.mountpoint.is_none()
|
||||
&& self.host_root.is_none()
|
||||
&& self.isolated_root.is_none()
|
||||
&& self.daemon.is_unassigned()
|
||||
&& self.mount.is_none()
|
||||
}
|
||||
|
||||
fn is_prepared(&self) -> bool {
|
||||
self.mountpoint.is_some()
|
||||
&& self.host_root.is_some()
|
||||
&& self.isolated_root.is_some()
|
||||
&& self.daemon.is_current_committed()
|
||||
&& self.mount.is_some()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(self.mountpoint.as_ref(), next.mountpoint.as_ref())?;
|
||||
validate_option_progress(self.host_root.as_ref(), next.host_root.as_ref())?;
|
||||
validate_option_progress(self.isolated_root.as_ref(), next.isolated_root.as_ref())?;
|
||||
validate_process_progress(&self.daemon, &next.daemon)?;
|
||||
validate_option_progress(self.mount.as_ref(), next.mount.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
derived_policy_digest: None,
|
||||
readiness_fingerprint: None,
|
||||
readiness_evidence: None,
|
||||
namespace: None,
|
||||
runtime_supervisor: PersistedHostProcessOwnership::unassigned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.derived_policy_digest.is_none()
|
||||
&& self.readiness_fingerprint.is_none()
|
||||
&& self.readiness_evidence.is_none()
|
||||
&& self.namespace.is_none()
|
||||
&& self.runtime_supervisor.is_unassigned()
|
||||
}
|
||||
|
||||
fn is_committed(&self, generation_id: GenerationId) -> bool {
|
||||
let (
|
||||
Some(derived_policy_digest),
|
||||
Some(readiness_fingerprint),
|
||||
Some(readiness_evidence),
|
||||
Some(namespace),
|
||||
Ok(runtime_supervisor),
|
||||
) = (
|
||||
self.derived_policy_digest,
|
||||
self.readiness_fingerprint,
|
||||
self.readiness_evidence.as_ref(),
|
||||
self.namespace,
|
||||
self.runtime_supervisor.serviceable_pidfs(),
|
||||
)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
readiness_evidence.matches(
|
||||
generation_id,
|
||||
derived_policy_digest,
|
||||
readiness_fingerprint,
|
||||
namespace,
|
||||
runtime_supervisor,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.derived_policy_digest.as_ref(),
|
||||
next.derived_policy_digest.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(
|
||||
self.readiness_fingerprint.as_ref(),
|
||||
next.readiness_fingerprint.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(
|
||||
self.readiness_evidence.as_ref(),
|
||||
next.readiness_evidence.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.namespace.as_ref(), next.namespace.as_ref())?;
|
||||
validate_existing_process_progress(&self.runtime_supervisor, &next.runtime_supervisor)
|
||||
}
|
||||
}
|
||||
|
||||
impl HelperOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
network_sidecar_id: None,
|
||||
connectivity_helper: PersistedHostProcessOwnership::unassigned(),
|
||||
runtime_supervisor: PersistedHostProcessOwnership::unassigned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.network_sidecar_id.is_none()
|
||||
&& self.connectivity_helper.is_unassigned()
|
||||
&& self.runtime_supervisor.is_unassigned()
|
||||
}
|
||||
|
||||
fn is_committed(&self) -> bool {
|
||||
self.network_sidecar_id.is_some()
|
||||
&& self.connectivity_helper.is_current_committed()
|
||||
&& self.runtime_supervisor.is_current_committed()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.network_sidecar_id.as_ref(),
|
||||
next.network_sidecar_id.as_ref(),
|
||||
)?;
|
||||
validate_process_progress(&self.connectivity_helper, &next.connectivity_helper)?;
|
||||
validate_existing_process_progress(&self.runtime_supervisor, &next.runtime_supervisor)
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
container_id: None,
|
||||
runtime_root: None,
|
||||
runtime_root_identity: None,
|
||||
bundle_digest: None,
|
||||
host_coordinator: PersistedHostProcessOwnership::unassigned(),
|
||||
runtime_supervisor: PersistedHostProcessOwnership::unassigned(),
|
||||
attached_runsc: PersistedHostProcessOwnership::unassigned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.container_id.is_none()
|
||||
&& self.runtime_root.is_none()
|
||||
&& self.runtime_root_identity.is_none()
|
||||
&& self.bundle_digest.is_none()
|
||||
&& self.host_coordinator.is_unassigned()
|
||||
&& self.runtime_supervisor.is_unassigned()
|
||||
&& self.attached_runsc.is_unassigned()
|
||||
}
|
||||
|
||||
fn is_committed(&self) -> bool {
|
||||
self.container_id.is_some()
|
||||
&& self.runtime_root.is_some()
|
||||
&& self.runtime_root_identity.is_some()
|
||||
&& self.bundle_digest.is_some()
|
||||
&& self.host_coordinator.is_current_committed()
|
||||
&& self.runtime_supervisor.is_current_committed()
|
||||
&& self.attached_runsc.is_current_committed()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(self.container_id.as_ref(), next.container_id.as_ref())?;
|
||||
validate_option_progress(self.runtime_root.as_ref(), next.runtime_root.as_ref())?;
|
||||
validate_option_progress(
|
||||
self.runtime_root_identity.as_ref(),
|
||||
next.runtime_root_identity.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.bundle_digest.as_ref(), next.bundle_digest.as_ref())?;
|
||||
validate_existing_process_progress(&self.host_coordinator, &next.host_coordinator)?;
|
||||
validate_process_progress(&self.runtime_supervisor, &next.runtime_supervisor)?;
|
||||
validate_process_progress(&self.attached_runsc, &next.attached_runsc)
|
||||
}
|
||||
}
|
||||
|
||||
impl PublicationOwnership {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
staged_socket_path: None,
|
||||
staged_identity_path: None,
|
||||
socket_path: None,
|
||||
identity_path: None,
|
||||
socket_identity: None,
|
||||
identity_file_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.staged_socket_path.is_none()
|
||||
&& self.staged_identity_path.is_none()
|
||||
&& self.socket_path.is_none()
|
||||
&& self.identity_path.is_none()
|
||||
&& self.socket_identity.is_none()
|
||||
&& self.identity_file_identity.is_none()
|
||||
}
|
||||
|
||||
fn is_committed(&self) -> bool {
|
||||
self.staged_socket_path.is_some()
|
||||
&& self.staged_identity_path.is_some()
|
||||
&& self.socket_path.is_some()
|
||||
&& self.identity_path.is_some()
|
||||
&& self.socket_identity.is_some()
|
||||
&& self.identity_file_identity.is_some()
|
||||
}
|
||||
|
||||
fn validate_progress_to(&self, next: &Self) -> Result<(), RecordTransitionError> {
|
||||
validate_option_progress(
|
||||
self.staged_socket_path.as_ref(),
|
||||
next.staged_socket_path.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(
|
||||
self.staged_identity_path.as_ref(),
|
||||
next.staged_identity_path.as_ref(),
|
||||
)?;
|
||||
validate_option_progress(self.socket_path.as_ref(), next.socket_path.as_ref())?;
|
||||
validate_option_progress(self.identity_path.as_ref(), next.identity_path.as_ref())?;
|
||||
validate_option_progress(self.socket_identity.as_ref(), next.socket_identity.as_ref())?;
|
||||
validate_option_progress(
|
||||
self.identity_file_identity.as_ref(),
|
||||
next.identity_file_identity.as_ref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_participant_progress(
|
||||
old: Option<&ParticipantOwnership>,
|
||||
next: Option<&ParticipantOwnership>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(None, None) => Ok(()),
|
||||
(None, Some(next)) if next.is_initial_progress() => Ok(()),
|
||||
(None, Some(_)) => Err(RecordTransitionError::ProcessStageSkipped),
|
||||
(Some(_), None) => Err(RecordTransitionError::EvidenceLost),
|
||||
(Some(old), Some(next)) => old.validate_progress_to(next),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_participant_release(
|
||||
old: Option<&ParticipantOwnership>,
|
||||
next: Option<&ParticipantOwnership>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(Some(old), Some(next)) => old.validate_release_to(next),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_participant_activation(
|
||||
old: Option<&ParticipantOwnership>,
|
||||
next: Option<&ParticipantOwnership>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(Some(old), Some(next)) => old.validate_progress_to(next),
|
||||
_ => Err(RecordTransitionError::PriorAcquisitionSnapshotRequired),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_prepared_lower_lease_progress(
|
||||
old: Option<ParticipantState>,
|
||||
next: Option<ParticipantState>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(None, None | Some(ParticipantState::Intent))
|
||||
| (
|
||||
Some(ParticipantState::Intent),
|
||||
Some(ParticipantState::Intent | ParticipantState::LeaseHeld),
|
||||
)
|
||||
| (Some(ParticipantState::LeaseHeld), Some(ParticipantState::LeaseHeld)) => Ok(()),
|
||||
(Some(_), None) => Err(RecordTransitionError::EvidenceLost),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_option_progress<T: Eq>(
|
||||
old: Option<&T>,
|
||||
next: Option<&T>,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(None, None | Some(_)) => Ok(()),
|
||||
(Some(_), None) => Err(RecordTransitionError::EvidenceLost),
|
||||
(Some(old), Some(next)) if old == next => Ok(()),
|
||||
(Some(_), Some(_)) => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_process_progress(
|
||||
old: &PersistedHostProcessOwnership,
|
||||
next: &PersistedHostProcessOwnership,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
match (old, next) {
|
||||
(
|
||||
PersistedHostProcessOwnership::Unassigned,
|
||||
PersistedHostProcessOwnership::Unassigned | PersistedHostProcessOwnership::Staged(_),
|
||||
) => Ok(()),
|
||||
(
|
||||
PersistedHostProcessOwnership::Unassigned,
|
||||
PersistedHostProcessOwnership::Committed(_),
|
||||
) => Err(RecordTransitionError::ProcessStageSkipped),
|
||||
(
|
||||
PersistedHostProcessOwnership::Staged(_) | PersistedHostProcessOwnership::Committed(_),
|
||||
PersistedHostProcessOwnership::Unassigned,
|
||||
) => Err(RecordTransitionError::EvidenceLost),
|
||||
(PersistedHostProcessOwnership::Committed(_), PersistedHostProcessOwnership::Staged(_)) => {
|
||||
Err(RecordTransitionError::EvidenceLost)
|
||||
}
|
||||
(
|
||||
PersistedHostProcessOwnership::Staged(old),
|
||||
PersistedHostProcessOwnership::Staged(next),
|
||||
) if old == next => Ok(()),
|
||||
(
|
||||
PersistedHostProcessOwnership::Staged(old),
|
||||
PersistedHostProcessOwnership::Committed(next),
|
||||
) if old == next.pidfs() => Ok(()),
|
||||
(
|
||||
PersistedHostProcessOwnership::Committed(old),
|
||||
PersistedHostProcessOwnership::Committed(next),
|
||||
) if old == next => Ok(()),
|
||||
_ => Err(RecordTransitionError::EvidenceChanged),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_existing_process_progress(
|
||||
old: &PersistedHostProcessOwnership,
|
||||
next: &PersistedHostProcessOwnership,
|
||||
) -> Result<(), RecordTransitionError> {
|
||||
if matches!(
|
||||
(old, next),
|
||||
(
|
||||
PersistedHostProcessOwnership::Unassigned,
|
||||
PersistedHostProcessOwnership::Committed(_)
|
||||
)
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
validate_process_progress(old, next)
|
||||
}
|
||||
}
|
||||
|
|
@ -417,675 +417,6 @@ fn require_inputs(inputs: &ColdStartInputs<'_>) -> Result<(), ColdStartError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
struct GitRoutingLayout {
|
||||
_runtime_directory: RuntimeGitRoutingDirectory,
|
||||
workspace: File,
|
||||
workspace_label: PathBuf,
|
||||
isolated: File,
|
||||
isolated_label: PathBuf,
|
||||
mountpoint: File,
|
||||
mountpoint_label: PathBuf,
|
||||
git_common: File,
|
||||
git_common_label: PathBuf,
|
||||
transient_hooks: File,
|
||||
transient_hooks_label: PathBuf,
|
||||
transient_config: File,
|
||||
transient_config_label: PathBuf,
|
||||
global_excludes: Option<PathBuf>,
|
||||
isolate_ignored: bool,
|
||||
}
|
||||
|
||||
impl GitRoutingLayout {
|
||||
fn create(
|
||||
configuration: &ResolvedDomainConfiguration,
|
||||
container_id: ContainerId,
|
||||
invoking_uid: u32,
|
||||
) -> Result<Option<Self>, ColdStartError> {
|
||||
let workspace_label = host_path(configuration.workspace().canonical_label().as_bytes());
|
||||
let workspace = File::from(
|
||||
configuration
|
||||
.workspace()
|
||||
.open_authority()
|
||||
.map_err(failure)?,
|
||||
);
|
||||
match statat(&workspace, ".git", AtFlags::SYMLINK_NOFOLLOW) {
|
||||
Ok(_) => {}
|
||||
Err(rustix::io::Errno::NOENT) => return Ok(None),
|
||||
Err(error) => return Err(failure(error)),
|
||||
}
|
||||
let persistent_root = configuration
|
||||
.persistent_domain_root()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
let isolated = open_or_create_private_directory(
|
||||
&persistent_root,
|
||||
"ignored-workspace",
|
||||
invoking_uid,
|
||||
false,
|
||||
)?;
|
||||
let runtime_root = configuration
|
||||
.runtime_domain_root()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
let runtime_parent =
|
||||
open_or_create_private_directory(&runtime_root, "git-routing", invoking_uid, false)?;
|
||||
let runtime_name = OsString::from(hex(container_id.as_bytes()));
|
||||
let directory =
|
||||
open_or_create_private_directory(&runtime_parent, &runtime_name, invoking_uid, true)?;
|
||||
let identity = FileIdentity::capture(&directory)?;
|
||||
let runtime_directory = RuntimeGitRoutingDirectory {
|
||||
parent: runtime_parent,
|
||||
directory,
|
||||
name: runtime_name.clone(),
|
||||
identity,
|
||||
};
|
||||
let mountpoint = open_or_create_private_directory(
|
||||
&runtime_directory.directory,
|
||||
"workspace",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?;
|
||||
let transient_hooks = open_or_create_private_directory(
|
||||
&runtime_directory.directory,
|
||||
"hooks",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?;
|
||||
let transient_config = openat2(
|
||||
&runtime_directory.directory,
|
||||
"config",
|
||||
OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::RUSR | Mode::WUSR,
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
fchmod(&transient_config, Mode::RUSR | Mode::WUSR).map_err(failure)?;
|
||||
require_private_file(&transient_config, invoking_uid, 0o600)?;
|
||||
let isolated_label = host_path(
|
||||
configuration
|
||||
.persistent_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes(),
|
||||
)
|
||||
.join("ignored-workspace");
|
||||
let runtime_label = host_path(
|
||||
configuration
|
||||
.runtime_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes(),
|
||||
)
|
||||
.join("git-routing")
|
||||
.join(&runtime_name);
|
||||
let mountpoint_label = runtime_label.join("workspace");
|
||||
let transient_hooks_label = runtime_label.join("hooks");
|
||||
let transient_config_label = runtime_label.join("config");
|
||||
let git_common_path = git_common_directory(&workspace_label).map_err(failure)?;
|
||||
let git_common = open_absolute_directory(&git_common_path)?;
|
||||
let global_excludes = global_excludes_path(&workspace_label)?;
|
||||
Ok(Some(Self {
|
||||
_runtime_directory: runtime_directory,
|
||||
workspace,
|
||||
workspace_label,
|
||||
isolated: File::from(isolated),
|
||||
isolated_label,
|
||||
mountpoint: File::from(mountpoint),
|
||||
mountpoint_label,
|
||||
git_common,
|
||||
git_common_label: git_common_path,
|
||||
transient_hooks: File::from(transient_hooks),
|
||||
transient_hooks_label,
|
||||
transient_config: File::from(transient_config),
|
||||
transient_config_label,
|
||||
global_excludes,
|
||||
isolate_ignored: configuration.fingerprint_record().isolate_gitignored,
|
||||
}))
|
||||
}
|
||||
|
||||
fn launch_authority(
|
||||
&self,
|
||||
configuration: &ResolvedDomainConfiguration,
|
||||
) -> Result<RuntimeGitRoutingLaunch, ColdStartError> {
|
||||
configuration
|
||||
.workspace()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
RuntimeGitRoutingLaunch::new(RuntimeGitRoutingAuthority {
|
||||
workspace: self.workspace.try_clone().map_err(failure)?,
|
||||
workspace_label: self.workspace_label.clone(),
|
||||
isolated: self.isolated.try_clone().map_err(failure)?,
|
||||
mountpoint: self.mountpoint.try_clone().map_err(failure)?,
|
||||
mountpoint_label: self.mountpoint_label.clone(),
|
||||
git_common: self.git_common.try_clone().map_err(failure)?,
|
||||
git_common_label: self.git_common_label.clone(),
|
||||
transient_hooks: self.transient_hooks.try_clone().map_err(failure)?,
|
||||
transient_hooks_label: self.transient_hooks_label.clone(),
|
||||
transient_config: self.transient_config.try_clone().map_err(failure)?,
|
||||
transient_config_label: self.transient_config_label.clone(),
|
||||
global_excludes: self.global_excludes.clone(),
|
||||
isolate_ignored: self.isolate_ignored,
|
||||
})
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
fn mountpoint(&self) -> HostPath {
|
||||
HostPath::new(
|
||||
self.mountpoint_label
|
||||
.as_os_str()
|
||||
.as_encoded_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
fn host_root(&self) -> HostPath {
|
||||
HostPath::new(self.workspace_label.as_os_str().as_encoded_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn isolated_root(&self) -> HostPath {
|
||||
HostPath::new(self.isolated_label.as_os_str().as_encoded_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
struct RuntimeGitRoutingDirectory {
|
||||
parent: OwnedFd,
|
||||
directory: OwnedFd,
|
||||
name: OsString,
|
||||
identity: FileIdentity,
|
||||
}
|
||||
|
||||
impl Drop for RuntimeGitRoutingDirectory {
|
||||
fn drop(&mut self) {
|
||||
remove_staging_directory(&self.parent, &self.name, &self.directory, self.identity);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_absolute_directory(path: &Path) -> Result<File, ColdStartError> {
|
||||
let descriptor = openat2(
|
||||
rustix::fs::CWD,
|
||||
path,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
Ok(File::from(descriptor))
|
||||
}
|
||||
|
||||
fn global_excludes_path(workspace: &Path) -> Result<Option<PathBuf>, ColdStartError> {
|
||||
let (reader, writer) = pipe_with(PipeFlags::CLOEXEC).map_err(failure)?;
|
||||
let target = TargetCommand::new("git")
|
||||
.argument("-C")
|
||||
.argument(workspace)
|
||||
.argument("config")
|
||||
.argument("--path")
|
||||
.argument("--get")
|
||||
.argument("--default")
|
||||
.argument("")
|
||||
.argument("core.excludesFile");
|
||||
child_broker()
|
||||
.run_transient_with_private_stdout(&target, writer)
|
||||
.map_err(|error| failure(format!("Git global-excludes discovery: {error}")))?;
|
||||
let mut output = Vec::new();
|
||||
File::from(reader)
|
||||
.read_to_end(&mut output)
|
||||
.map_err(failure)?;
|
||||
parse_global_excludes_path(workspace, &output)
|
||||
}
|
||||
|
||||
fn parse_global_excludes_path(
|
||||
workspace: &Path,
|
||||
output: &[u8],
|
||||
) -> Result<Option<PathBuf>, ColdStartError> {
|
||||
let value = output.strip_suffix(b"\n").unwrap_or(output);
|
||||
if value.contains(&0) {
|
||||
return Err(ColdStartError("Git global-excludes path is invalid".into()));
|
||||
}
|
||||
if value.is_empty() {
|
||||
return Ok(default_global_excludes_path());
|
||||
}
|
||||
let path = PathBuf::from(OsString::from_vec(value.to_vec()));
|
||||
Ok(Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
workspace.join(path)
|
||||
}))
|
||||
}
|
||||
|
||||
fn default_global_excludes_path() -> Option<PathBuf> {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME")
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(|home| PathBuf::from(home).join(".config"))
|
||||
})
|
||||
.map(|home| home.join("git/ignore"))
|
||||
}
|
||||
|
||||
pub(crate) struct RuntimeStaging {
|
||||
runsc_root: OwnedFd,
|
||||
bundles: OwnedFd,
|
||||
bundle: OwnedFd,
|
||||
controls: OwnedFd,
|
||||
control: OwnedFd,
|
||||
overlays: OwnedFd,
|
||||
overlay: OwnedFd,
|
||||
name: OsString,
|
||||
bundle_identity: FileIdentity,
|
||||
control_identity: FileIdentity,
|
||||
overlay_identity: FileIdentity,
|
||||
}
|
||||
|
||||
impl RuntimeStaging {
|
||||
pub(crate) fn runsc_root_identity(&self) -> Result<MountIdentity, ColdStartError> {
|
||||
mount_identity(&self.runsc_root).map_err(failure)
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup_overlay(&self) -> Result<(), ColdStartError> {
|
||||
let retained = fstat(&self.overlay).map_err(failure)?;
|
||||
if !self.overlay_identity.matches(&retained) {
|
||||
return Err(ColdStartError(
|
||||
"runsc overlay retained authority changed".into(),
|
||||
));
|
||||
}
|
||||
remove_children(&self.overlay).map_err(failure)?;
|
||||
let current =
|
||||
statat(&self.overlays, &self.name, AtFlags::SYMLINK_NOFOLLOW).map_err(failure)?;
|
||||
if !self.overlay_identity.matches(¤t)
|
||||
|| !FileType::from_raw_mode(current.st_mode).is_dir()
|
||||
{
|
||||
return Err(ColdStartError(
|
||||
"runsc overlay path changed before cleanup".into(),
|
||||
));
|
||||
}
|
||||
unlinkat(&self.overlays, &self.name, AtFlags::REMOVEDIR).map_err(failure)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn escalation_mailboxes(
|
||||
&self,
|
||||
invoking_uid: u32,
|
||||
) -> Result<(RegularFileMailbox, RegularFileMailbox, RegularFileMailbox), ColdStartError> {
|
||||
let credentials = RegularFileMailbox::openat_private(
|
||||
&self.control,
|
||||
OsStr::new("credentials"),
|
||||
invoking_uid,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let mailbox =
|
||||
RegularFileMailbox::openat_private(&self.control, OsStr::new("mailbox"), invoking_uid)
|
||||
.map_err(failure)?;
|
||||
let requests =
|
||||
RegularFileMailbox::openat_private(&mailbox, OsStr::new("requests"), invoking_uid)
|
||||
.map_err(failure)?;
|
||||
let results =
|
||||
RegularFileMailbox::openat_private(&mailbox, OsStr::new("results"), invoking_uid)
|
||||
.map_err(failure)?;
|
||||
Ok((credentials, requests, results))
|
||||
}
|
||||
|
||||
fn create(
|
||||
runtime_domain: &ConfinedPathPlan,
|
||||
container_id: ContainerId,
|
||||
escalation_client: &File,
|
||||
sandbox_init: &File,
|
||||
invoking_uid: u32,
|
||||
) -> Result<Self, ColdStartError> {
|
||||
let root = runtime_domain.open_authority().map_err(failure)?;
|
||||
let bundles = open_or_create_private_directory(&root, "bundles", invoking_uid, false)?;
|
||||
let controls =
|
||||
open_or_create_private_directory(&root, "sandbox-control", invoking_uid, false)?;
|
||||
let overlays =
|
||||
open_or_create_private_directory(&root, "runsc-overlay", invoking_uid, false)?;
|
||||
let runsc_root = open_or_create_private_directory(&root, "runsc", invoking_uid, false)?;
|
||||
let name = OsString::from(hex(container_id.as_bytes()));
|
||||
let bundle = open_or_create_private_directory(&bundles, &name, invoking_uid, true)?;
|
||||
let bundle_identity = FileIdentity::capture(&bundle)?;
|
||||
let control = match open_or_create_private_directory(&controls, &name, invoking_uid, true) {
|
||||
Ok(control) => control,
|
||||
Err(error) => {
|
||||
remove_staging_directory(&bundles, &name, &bundle, bundle_identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let control_identity = FileIdentity::capture(&control)?;
|
||||
let overlay = match open_or_create_private_directory(&overlays, &name, invoking_uid, true) {
|
||||
Ok(overlay) => overlay,
|
||||
Err(error) => {
|
||||
remove_staging_directory(&controls, &name, &control, control_identity);
|
||||
remove_staging_directory(&bundles, &name, &bundle, bundle_identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let overlay_identity = FileIdentity::capture(&overlay)?;
|
||||
let staging = Self {
|
||||
runsc_root,
|
||||
bundles,
|
||||
bundle,
|
||||
controls,
|
||||
control,
|
||||
overlays,
|
||||
overlay,
|
||||
name,
|
||||
bundle_identity,
|
||||
control_identity,
|
||||
overlay_identity,
|
||||
};
|
||||
staging.populate_control(escalation_client, sandbox_init)?;
|
||||
Ok(staging)
|
||||
}
|
||||
|
||||
fn populate_control(
|
||||
&self,
|
||||
escalation_client: &File,
|
||||
sandbox_init: &File,
|
||||
) -> Result<(), ColdStartError> {
|
||||
let invoking_uid = fstat(&self.control).map_err(failure)?.st_uid;
|
||||
drop(open_or_create_private_directory(
|
||||
&self.control,
|
||||
"credentials",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?);
|
||||
let mailbox =
|
||||
open_or_create_private_directory(&self.control, "mailbox", invoking_uid, true)?;
|
||||
drop(open_or_create_private_directory(
|
||||
&mailbox,
|
||||
"requests",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?);
|
||||
drop(open_or_create_private_directory(
|
||||
&mailbox,
|
||||
"results",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?);
|
||||
let bin = open_or_create_private_directory(&self.control, "bin", invoking_uid, true)?;
|
||||
require_executable_regular_file(escalation_client, "escalation client")?;
|
||||
copy_descriptor(escalation_client, &bin, "lazaret-escalate", invoking_uid)?;
|
||||
require_executable_regular_file(sandbox_init, "sandbox init")?;
|
||||
copy_descriptor(sandbox_init, &bin, "lazaret-sandbox-init", invoking_uid)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_bundle(&self, bytes: &[u8]) -> Result<(), ColdStartError> {
|
||||
let descriptor = openat2(
|
||||
&self.bundle,
|
||||
"config.json",
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::RUSR | Mode::WUSR,
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let mut file = File::from(descriptor);
|
||||
file.write_all(bytes).map_err(failure)?;
|
||||
fchmod(file.as_fd(), Mode::RUSR | Mode::WUSR).map_err(failure)?;
|
||||
file.sync_all().map_err(failure)?;
|
||||
require_private_file(&file, fstat(&self.bundle).map_err(failure)?.st_uid, 0o600)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_descriptor(
|
||||
source: &File,
|
||||
parent: &OwnedFd,
|
||||
name: &str,
|
||||
invoking_uid: u32,
|
||||
) -> Result<(), ColdStartError> {
|
||||
let descriptor = openat2(
|
||||
parent,
|
||||
name,
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::RWXU,
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let mut destination = File::from(descriptor);
|
||||
fchmod(destination.as_fd(), Mode::RWXU).map_err(failure)?;
|
||||
let mut offset = 0_u64;
|
||||
let mut buffer = [0_u8; 16 * 1024];
|
||||
loop {
|
||||
let read = source.read_at(&mut buffer, offset).map_err(failure)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
destination.write_all(&buffer[..read]).map_err(failure)?;
|
||||
offset = offset
|
||||
.checked_add(read as u64)
|
||||
.ok_or_else(|| ColdStartError("escalation client is too large".into()))?;
|
||||
}
|
||||
destination.sync_all().map_err(failure)?;
|
||||
require_private_file(&destination, invoking_uid, 0o700)
|
||||
}
|
||||
|
||||
fn require_executable_regular_file(source: &File, label: &str) -> Result<(), ColdStartError> {
|
||||
let metadata = source
|
||||
.metadata()
|
||||
.map_err(|error| failure(format!("inspecting {label}: {error}")))?;
|
||||
if !metadata.file_type().is_file() || metadata.permissions().mode() & 0o111 == 0 {
|
||||
return Err(ColdStartError(format!(
|
||||
"{label} is not an executable regular file"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FileIdentity {
|
||||
device: u64,
|
||||
inode: u64,
|
||||
}
|
||||
|
||||
impl FileIdentity {
|
||||
fn capture(descriptor: impl AsFd) -> Result<Self, ColdStartError> {
|
||||
let metadata = fstat(descriptor).map_err(failure)?;
|
||||
Ok(Self {
|
||||
device: metadata.st_dev,
|
||||
inode: metadata.st_ino,
|
||||
})
|
||||
}
|
||||
|
||||
fn matches(self, metadata: &rustix::fs::Stat) -> bool {
|
||||
metadata.st_dev == self.device && metadata.st_ino == self.inode
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RuntimeStaging {
|
||||
fn drop(&mut self) {
|
||||
remove_staging_directory(
|
||||
&self.overlays,
|
||||
&self.name,
|
||||
&self.overlay,
|
||||
self.overlay_identity,
|
||||
);
|
||||
remove_staging_directory(
|
||||
&self.controls,
|
||||
&self.name,
|
||||
&self.control,
|
||||
self.control_identity,
|
||||
);
|
||||
remove_staging_directory(
|
||||
&self.bundles,
|
||||
&self.name,
|
||||
&self.bundle,
|
||||
self.bundle_identity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_or_create_private_directory(
|
||||
parent: impl AsFd,
|
||||
name: impl AsRef<std::ffi::OsStr>,
|
||||
invoking_uid: u32,
|
||||
require_new: bool,
|
||||
) -> Result<OwnedFd, ColdStartError> {
|
||||
let name = name.as_ref();
|
||||
let created = match mkdirat(parent.as_fd(), name, Mode::RWXU) {
|
||||
Ok(()) => true,
|
||||
Err(rustix::io::Errno::EXIST) if !require_new => false,
|
||||
Err(error) => return Err(failure(error)),
|
||||
};
|
||||
let directory = openat2(
|
||||
parent.as_fd(),
|
||||
name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
if created {
|
||||
fchmod(&directory, Mode::RWXU).map_err(failure)?;
|
||||
}
|
||||
require_private_directory(&directory, invoking_uid)?;
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn require_private_directory(
|
||||
descriptor: impl AsFd,
|
||||
invoking_uid: u32,
|
||||
) -> Result<(), ColdStartError> {
|
||||
let metadata = fstat(descriptor).map_err(failure)?;
|
||||
if !FileType::from_raw_mode(metadata.st_mode).is_dir()
|
||||
|| metadata.st_uid != invoking_uid
|
||||
|| metadata.st_mode & 0o7777 != 0o700
|
||||
{
|
||||
return Err(ColdStartError(
|
||||
"runtime staging directory is not invoking-user-owned mode 0700".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_private_file(
|
||||
descriptor: impl AsFd,
|
||||
invoking_uid: u32,
|
||||
mode: u32,
|
||||
) -> Result<(), ColdStartError> {
|
||||
let metadata = fstat(descriptor).map_err(failure)?;
|
||||
if !FileType::from_raw_mode(metadata.st_mode).is_file()
|
||||
|| metadata.st_uid != invoking_uid
|
||||
|| metadata.st_mode & 0o7777 != mode
|
||||
|| metadata.st_nlink != 1
|
||||
{
|
||||
return Err(ColdStartError(
|
||||
"runtime staging file has unsafe type, ownership, mode, or link count".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_staging_directory(
|
||||
parent: impl AsFd,
|
||||
name: &std::ffi::OsStr,
|
||||
directory: impl AsFd,
|
||||
expected: FileIdentity,
|
||||
) {
|
||||
let retained = fstat(directory.as_fd());
|
||||
if !retained
|
||||
.as_ref()
|
||||
.is_ok_and(|metadata| expected.matches(metadata))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if remove_children(directory.as_fd()).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(current) = statat(parent.as_fd(), name, AtFlags::SYMLINK_NOFOLLOW) else {
|
||||
return;
|
||||
};
|
||||
if expected.matches(¤t) && FileType::from_raw_mode(current.st_mode).is_dir() {
|
||||
let _ = unlinkat(parent.as_fd(), name, AtFlags::REMOVEDIR);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_children(directory: impl AsFd) -> std::io::Result<()> {
|
||||
let duplicate = rustix::io::dup(directory.as_fd())?;
|
||||
let mut buffer = [std::mem::MaybeUninit::uninit(); 8192];
|
||||
let names = {
|
||||
let mut names = Vec::new();
|
||||
let mut entries = RawDir::new(duplicate, &mut buffer);
|
||||
while let Some(entry) = entries.next() {
|
||||
let entry = entry?;
|
||||
let bytes = entry.file_name().to_bytes();
|
||||
if bytes != b"." && bytes != b".." {
|
||||
names.push(OsString::from_vec(bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
names
|
||||
};
|
||||
for name in names {
|
||||
let metadata = statat(directory.as_fd(), &name, AtFlags::SYMLINK_NOFOLLOW)?;
|
||||
if FileType::from_raw_mode(metadata.st_mode).is_dir() {
|
||||
let child = openat2(
|
||||
directory.as_fd(),
|
||||
&name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
confined_resolve_flags(),
|
||||
)?;
|
||||
remove_children(&child)?;
|
||||
unlinkat(directory.as_fd(), &name, AtFlags::REMOVEDIR)?;
|
||||
} else {
|
||||
unlinkat(directory.as_fd(), &name, AtFlags::empty())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn confined_resolve_flags() -> ResolveFlags {
|
||||
ResolveFlags::BENEATH
|
||||
| ResolveFlags::NO_MAGICLINKS
|
||||
| ResolveFlags::NO_SYMLINKS
|
||||
| ResolveFlags::NO_XDEV
|
||||
}
|
||||
|
||||
fn host_path(bytes: &[u8]) -> PathBuf {
|
||||
PathBuf::from(OsString::from_vec(bytes.to_vec()))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
bytes.iter().fold(String::new(), |mut output, byte| {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs::File;
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
use rustix::pipe::{PipeFlags, pipe_with};
|
||||
|
||||
use super::BootstrapOwnerGuard;
|
||||
|
||||
#[test]
|
||||
fn bootstrap_owner_guard_tracks_the_exact_inherited_pipe() {
|
||||
let (reader, writer) = pipe_with(PipeFlags::CLOEXEC).unwrap();
|
||||
let guard = BootstrapOwnerGuard::from_inherited(reader).unwrap();
|
||||
assert!(guard.is_live().unwrap());
|
||||
drop(writer);
|
||||
assert!(!guard.is_live().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_owner_guard_refuses_non_pipe_authority() {
|
||||
let descriptor: OwnedFd = File::open("/dev/null").unwrap().into();
|
||||
assert!(BootstrapOwnerGuard::from_inherited(descriptor).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_relative_global_excludes_are_workspace_relative() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let workspace = root.path().join("workspace");
|
||||
|
||||
assert_eq!(
|
||||
super::parse_global_excludes_path(&workspace, b"policy/ignore\n").unwrap(),
|
||||
Some(workspace.join("policy/ignore"))
|
||||
);
|
||||
assert!(super::parse_global_excludes_path(&workspace, b"bad\0path\n").is_err());
|
||||
}
|
||||
}
|
||||
include!("cold_start/git_routing.rs");
|
||||
include!("cold_start/runtime_staging.rs");
|
||||
include!("cold_start/tests.rs");
|
||||
|
|
|
|||
245
crates/lazaret-host-coordinator/src/cold_start/git_routing.rs
Normal file
245
crates/lazaret-host-coordinator/src/cold_start/git_routing.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
struct GitRoutingLayout {
|
||||
_runtime_directory: RuntimeGitRoutingDirectory,
|
||||
workspace: File,
|
||||
workspace_label: PathBuf,
|
||||
isolated: File,
|
||||
isolated_label: PathBuf,
|
||||
mountpoint: File,
|
||||
mountpoint_label: PathBuf,
|
||||
git_common: File,
|
||||
git_common_label: PathBuf,
|
||||
transient_hooks: File,
|
||||
transient_hooks_label: PathBuf,
|
||||
transient_config: File,
|
||||
transient_config_label: PathBuf,
|
||||
global_excludes: Option<PathBuf>,
|
||||
isolate_ignored: bool,
|
||||
}
|
||||
|
||||
impl GitRoutingLayout {
|
||||
fn create(
|
||||
configuration: &ResolvedDomainConfiguration,
|
||||
container_id: ContainerId,
|
||||
invoking_uid: u32,
|
||||
) -> Result<Option<Self>, ColdStartError> {
|
||||
let workspace_label = host_path(configuration.workspace().canonical_label().as_bytes());
|
||||
let workspace = File::from(
|
||||
configuration
|
||||
.workspace()
|
||||
.open_authority()
|
||||
.map_err(failure)?,
|
||||
);
|
||||
match statat(&workspace, ".git", AtFlags::SYMLINK_NOFOLLOW) {
|
||||
Ok(_) => {}
|
||||
Err(rustix::io::Errno::NOENT) => return Ok(None),
|
||||
Err(error) => return Err(failure(error)),
|
||||
}
|
||||
let persistent_root = configuration
|
||||
.persistent_domain_root()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
let isolated = open_or_create_private_directory(
|
||||
&persistent_root,
|
||||
"ignored-workspace",
|
||||
invoking_uid,
|
||||
false,
|
||||
)?;
|
||||
let runtime_root = configuration
|
||||
.runtime_domain_root()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
let runtime_parent =
|
||||
open_or_create_private_directory(&runtime_root, "git-routing", invoking_uid, false)?;
|
||||
let runtime_name = OsString::from(hex(container_id.as_bytes()));
|
||||
let directory =
|
||||
open_or_create_private_directory(&runtime_parent, &runtime_name, invoking_uid, true)?;
|
||||
let identity = FileIdentity::capture(&directory)?;
|
||||
let runtime_directory = RuntimeGitRoutingDirectory {
|
||||
parent: runtime_parent,
|
||||
directory,
|
||||
name: runtime_name.clone(),
|
||||
identity,
|
||||
};
|
||||
let mountpoint = open_or_create_private_directory(
|
||||
&runtime_directory.directory,
|
||||
"workspace",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?;
|
||||
let transient_hooks = open_or_create_private_directory(
|
||||
&runtime_directory.directory,
|
||||
"hooks",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?;
|
||||
let transient_config = openat2(
|
||||
&runtime_directory.directory,
|
||||
"config",
|
||||
OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::RUSR | Mode::WUSR,
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
fchmod(&transient_config, Mode::RUSR | Mode::WUSR).map_err(failure)?;
|
||||
require_private_file(&transient_config, invoking_uid, 0o600)?;
|
||||
let isolated_label = host_path(
|
||||
configuration
|
||||
.persistent_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes(),
|
||||
)
|
||||
.join("ignored-workspace");
|
||||
let runtime_label = host_path(
|
||||
configuration
|
||||
.runtime_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes(),
|
||||
)
|
||||
.join("git-routing")
|
||||
.join(&runtime_name);
|
||||
let mountpoint_label = runtime_label.join("workspace");
|
||||
let transient_hooks_label = runtime_label.join("hooks");
|
||||
let transient_config_label = runtime_label.join("config");
|
||||
let git_common_path = git_common_directory(&workspace_label).map_err(failure)?;
|
||||
let git_common = open_absolute_directory(&git_common_path)?;
|
||||
let global_excludes = global_excludes_path(&workspace_label)?;
|
||||
Ok(Some(Self {
|
||||
_runtime_directory: runtime_directory,
|
||||
workspace,
|
||||
workspace_label,
|
||||
isolated: File::from(isolated),
|
||||
isolated_label,
|
||||
mountpoint: File::from(mountpoint),
|
||||
mountpoint_label,
|
||||
git_common,
|
||||
git_common_label: git_common_path,
|
||||
transient_hooks: File::from(transient_hooks),
|
||||
transient_hooks_label,
|
||||
transient_config: File::from(transient_config),
|
||||
transient_config_label,
|
||||
global_excludes,
|
||||
isolate_ignored: configuration.fingerprint_record().isolate_gitignored,
|
||||
}))
|
||||
}
|
||||
|
||||
fn launch_authority(
|
||||
&self,
|
||||
configuration: &ResolvedDomainConfiguration,
|
||||
) -> Result<RuntimeGitRoutingLaunch, ColdStartError> {
|
||||
configuration
|
||||
.workspace()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
RuntimeGitRoutingLaunch::new(RuntimeGitRoutingAuthority {
|
||||
workspace: self.workspace.try_clone().map_err(failure)?,
|
||||
workspace_label: self.workspace_label.clone(),
|
||||
isolated: self.isolated.try_clone().map_err(failure)?,
|
||||
mountpoint: self.mountpoint.try_clone().map_err(failure)?,
|
||||
mountpoint_label: self.mountpoint_label.clone(),
|
||||
git_common: self.git_common.try_clone().map_err(failure)?,
|
||||
git_common_label: self.git_common_label.clone(),
|
||||
transient_hooks: self.transient_hooks.try_clone().map_err(failure)?,
|
||||
transient_hooks_label: self.transient_hooks_label.clone(),
|
||||
transient_config: self.transient_config.try_clone().map_err(failure)?,
|
||||
transient_config_label: self.transient_config_label.clone(),
|
||||
global_excludes: self.global_excludes.clone(),
|
||||
isolate_ignored: self.isolate_ignored,
|
||||
})
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
fn mountpoint(&self) -> HostPath {
|
||||
HostPath::new(
|
||||
self.mountpoint_label
|
||||
.as_os_str()
|
||||
.as_encoded_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
fn host_root(&self) -> HostPath {
|
||||
HostPath::new(self.workspace_label.as_os_str().as_encoded_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn isolated_root(&self) -> HostPath {
|
||||
HostPath::new(self.isolated_label.as_os_str().as_encoded_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
struct RuntimeGitRoutingDirectory {
|
||||
parent: OwnedFd,
|
||||
directory: OwnedFd,
|
||||
name: OsString,
|
||||
identity: FileIdentity,
|
||||
}
|
||||
|
||||
impl Drop for RuntimeGitRoutingDirectory {
|
||||
fn drop(&mut self) {
|
||||
remove_staging_directory(&self.parent, &self.name, &self.directory, self.identity);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_absolute_directory(path: &Path) -> Result<File, ColdStartError> {
|
||||
let descriptor = openat2(
|
||||
rustix::fs::CWD,
|
||||
path,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
Ok(File::from(descriptor))
|
||||
}
|
||||
|
||||
fn global_excludes_path(workspace: &Path) -> Result<Option<PathBuf>, ColdStartError> {
|
||||
let (reader, writer) = pipe_with(PipeFlags::CLOEXEC).map_err(failure)?;
|
||||
let target = TargetCommand::new("git")
|
||||
.argument("-C")
|
||||
.argument(workspace)
|
||||
.argument("config")
|
||||
.argument("--path")
|
||||
.argument("--get")
|
||||
.argument("--default")
|
||||
.argument("")
|
||||
.argument("core.excludesFile");
|
||||
child_broker()
|
||||
.run_transient_with_private_stdout(&target, writer)
|
||||
.map_err(|error| failure(format!("Git global-excludes discovery: {error}")))?;
|
||||
let mut output = Vec::new();
|
||||
File::from(reader)
|
||||
.read_to_end(&mut output)
|
||||
.map_err(failure)?;
|
||||
parse_global_excludes_path(workspace, &output)
|
||||
}
|
||||
|
||||
fn parse_global_excludes_path(
|
||||
workspace: &Path,
|
||||
output: &[u8],
|
||||
) -> Result<Option<PathBuf>, ColdStartError> {
|
||||
let value = output.strip_suffix(b"\n").unwrap_or(output);
|
||||
if value.contains(&0) {
|
||||
return Err(ColdStartError("Git global-excludes path is invalid".into()));
|
||||
}
|
||||
if value.is_empty() {
|
||||
return Ok(default_global_excludes_path());
|
||||
}
|
||||
let path = PathBuf::from(OsString::from_vec(value.to_vec()));
|
||||
Ok(Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
workspace.join(path)
|
||||
}))
|
||||
}
|
||||
|
||||
fn default_global_excludes_path() -> Option<PathBuf> {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME")
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(|home| PathBuf::from(home).join(".config"))
|
||||
})
|
||||
.map(|home| home.join("git/ignore"))
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
pub(crate) struct RuntimeStaging {
|
||||
runsc_root: OwnedFd,
|
||||
bundles: OwnedFd,
|
||||
bundle: OwnedFd,
|
||||
controls: OwnedFd,
|
||||
control: OwnedFd,
|
||||
overlays: OwnedFd,
|
||||
overlay: OwnedFd,
|
||||
name: OsString,
|
||||
bundle_identity: FileIdentity,
|
||||
control_identity: FileIdentity,
|
||||
overlay_identity: FileIdentity,
|
||||
}
|
||||
|
||||
impl RuntimeStaging {
|
||||
pub(crate) fn runsc_root_identity(&self) -> Result<MountIdentity, ColdStartError> {
|
||||
mount_identity(&self.runsc_root).map_err(failure)
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup_overlay(&self) -> Result<(), ColdStartError> {
|
||||
let retained = fstat(&self.overlay).map_err(failure)?;
|
||||
if !self.overlay_identity.matches(&retained) {
|
||||
return Err(ColdStartError(
|
||||
"runsc overlay retained authority changed".into(),
|
||||
));
|
||||
}
|
||||
remove_children(&self.overlay).map_err(failure)?;
|
||||
let current =
|
||||
statat(&self.overlays, &self.name, AtFlags::SYMLINK_NOFOLLOW).map_err(failure)?;
|
||||
if !self.overlay_identity.matches(¤t)
|
||||
|| !FileType::from_raw_mode(current.st_mode).is_dir()
|
||||
{
|
||||
return Err(ColdStartError(
|
||||
"runsc overlay path changed before cleanup".into(),
|
||||
));
|
||||
}
|
||||
unlinkat(&self.overlays, &self.name, AtFlags::REMOVEDIR).map_err(failure)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn escalation_mailboxes(
|
||||
&self,
|
||||
invoking_uid: u32,
|
||||
) -> Result<(RegularFileMailbox, RegularFileMailbox, RegularFileMailbox), ColdStartError> {
|
||||
let credentials = RegularFileMailbox::openat_private(
|
||||
&self.control,
|
||||
OsStr::new("credentials"),
|
||||
invoking_uid,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let mailbox =
|
||||
RegularFileMailbox::openat_private(&self.control, OsStr::new("mailbox"), invoking_uid)
|
||||
.map_err(failure)?;
|
||||
let requests =
|
||||
RegularFileMailbox::openat_private(&mailbox, OsStr::new("requests"), invoking_uid)
|
||||
.map_err(failure)?;
|
||||
let results =
|
||||
RegularFileMailbox::openat_private(&mailbox, OsStr::new("results"), invoking_uid)
|
||||
.map_err(failure)?;
|
||||
Ok((credentials, requests, results))
|
||||
}
|
||||
|
||||
fn create(
|
||||
runtime_domain: &ConfinedPathPlan,
|
||||
container_id: ContainerId,
|
||||
escalation_client: &File,
|
||||
sandbox_init: &File,
|
||||
invoking_uid: u32,
|
||||
) -> Result<Self, ColdStartError> {
|
||||
let root = runtime_domain.open_authority().map_err(failure)?;
|
||||
let bundles = open_or_create_private_directory(&root, "bundles", invoking_uid, false)?;
|
||||
let controls =
|
||||
open_or_create_private_directory(&root, "sandbox-control", invoking_uid, false)?;
|
||||
let overlays =
|
||||
open_or_create_private_directory(&root, "runsc-overlay", invoking_uid, false)?;
|
||||
let runsc_root = open_or_create_private_directory(&root, "runsc", invoking_uid, false)?;
|
||||
let name = OsString::from(hex(container_id.as_bytes()));
|
||||
let bundle = open_or_create_private_directory(&bundles, &name, invoking_uid, true)?;
|
||||
let bundle_identity = FileIdentity::capture(&bundle)?;
|
||||
let control = match open_or_create_private_directory(&controls, &name, invoking_uid, true) {
|
||||
Ok(control) => control,
|
||||
Err(error) => {
|
||||
remove_staging_directory(&bundles, &name, &bundle, bundle_identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let control_identity = FileIdentity::capture(&control)?;
|
||||
let overlay = match open_or_create_private_directory(&overlays, &name, invoking_uid, true) {
|
||||
Ok(overlay) => overlay,
|
||||
Err(error) => {
|
||||
remove_staging_directory(&controls, &name, &control, control_identity);
|
||||
remove_staging_directory(&bundles, &name, &bundle, bundle_identity);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let overlay_identity = FileIdentity::capture(&overlay)?;
|
||||
let staging = Self {
|
||||
runsc_root,
|
||||
bundles,
|
||||
bundle,
|
||||
controls,
|
||||
control,
|
||||
overlays,
|
||||
overlay,
|
||||
name,
|
||||
bundle_identity,
|
||||
control_identity,
|
||||
overlay_identity,
|
||||
};
|
||||
staging.populate_control(escalation_client, sandbox_init)?;
|
||||
Ok(staging)
|
||||
}
|
||||
|
||||
fn populate_control(
|
||||
&self,
|
||||
escalation_client: &File,
|
||||
sandbox_init: &File,
|
||||
) -> Result<(), ColdStartError> {
|
||||
let invoking_uid = fstat(&self.control).map_err(failure)?.st_uid;
|
||||
drop(open_or_create_private_directory(
|
||||
&self.control,
|
||||
"credentials",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?);
|
||||
let mailbox =
|
||||
open_or_create_private_directory(&self.control, "mailbox", invoking_uid, true)?;
|
||||
drop(open_or_create_private_directory(
|
||||
&mailbox,
|
||||
"requests",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?);
|
||||
drop(open_or_create_private_directory(
|
||||
&mailbox,
|
||||
"results",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?);
|
||||
let bin = open_or_create_private_directory(&self.control, "bin", invoking_uid, true)?;
|
||||
require_executable_regular_file(escalation_client, "escalation client")?;
|
||||
copy_descriptor(escalation_client, &bin, "lazaret-escalate", invoking_uid)?;
|
||||
require_executable_regular_file(sandbox_init, "sandbox init")?;
|
||||
copy_descriptor(sandbox_init, &bin, "lazaret-sandbox-init", invoking_uid)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_bundle(&self, bytes: &[u8]) -> Result<(), ColdStartError> {
|
||||
let descriptor = openat2(
|
||||
&self.bundle,
|
||||
"config.json",
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::RUSR | Mode::WUSR,
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let mut file = File::from(descriptor);
|
||||
file.write_all(bytes).map_err(failure)?;
|
||||
fchmod(file.as_fd(), Mode::RUSR | Mode::WUSR).map_err(failure)?;
|
||||
file.sync_all().map_err(failure)?;
|
||||
require_private_file(&file, fstat(&self.bundle).map_err(failure)?.st_uid, 0o600)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_descriptor(
|
||||
source: &File,
|
||||
parent: &OwnedFd,
|
||||
name: &str,
|
||||
invoking_uid: u32,
|
||||
) -> Result<(), ColdStartError> {
|
||||
let descriptor = openat2(
|
||||
parent,
|
||||
name,
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::RWXU,
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let mut destination = File::from(descriptor);
|
||||
fchmod(destination.as_fd(), Mode::RWXU).map_err(failure)?;
|
||||
let mut offset = 0_u64;
|
||||
let mut buffer = [0_u8; 16 * 1024];
|
||||
loop {
|
||||
let read = source.read_at(&mut buffer, offset).map_err(failure)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
destination.write_all(&buffer[..read]).map_err(failure)?;
|
||||
offset = offset
|
||||
.checked_add(read as u64)
|
||||
.ok_or_else(|| ColdStartError("escalation client is too large".into()))?;
|
||||
}
|
||||
destination.sync_all().map_err(failure)?;
|
||||
require_private_file(&destination, invoking_uid, 0o700)
|
||||
}
|
||||
|
||||
fn require_executable_regular_file(source: &File, label: &str) -> Result<(), ColdStartError> {
|
||||
let metadata = source
|
||||
.metadata()
|
||||
.map_err(|error| failure(format!("inspecting {label}: {error}")))?;
|
||||
if !metadata.file_type().is_file() || metadata.permissions().mode() & 0o111 == 0 {
|
||||
return Err(ColdStartError(format!(
|
||||
"{label} is not an executable regular file"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FileIdentity {
|
||||
device: u64,
|
||||
inode: u64,
|
||||
}
|
||||
|
||||
impl FileIdentity {
|
||||
fn capture(descriptor: impl AsFd) -> Result<Self, ColdStartError> {
|
||||
let metadata = fstat(descriptor).map_err(failure)?;
|
||||
Ok(Self {
|
||||
device: metadata.st_dev,
|
||||
inode: metadata.st_ino,
|
||||
})
|
||||
}
|
||||
|
||||
fn matches(self, metadata: &rustix::fs::Stat) -> bool {
|
||||
metadata.st_dev == self.device && metadata.st_ino == self.inode
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RuntimeStaging {
|
||||
fn drop(&mut self) {
|
||||
remove_staging_directory(
|
||||
&self.overlays,
|
||||
&self.name,
|
||||
&self.overlay,
|
||||
self.overlay_identity,
|
||||
);
|
||||
remove_staging_directory(
|
||||
&self.controls,
|
||||
&self.name,
|
||||
&self.control,
|
||||
self.control_identity,
|
||||
);
|
||||
remove_staging_directory(
|
||||
&self.bundles,
|
||||
&self.name,
|
||||
&self.bundle,
|
||||
self.bundle_identity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_or_create_private_directory(
|
||||
parent: impl AsFd,
|
||||
name: impl AsRef<std::ffi::OsStr>,
|
||||
invoking_uid: u32,
|
||||
require_new: bool,
|
||||
) -> Result<OwnedFd, ColdStartError> {
|
||||
let name = name.as_ref();
|
||||
let created = match mkdirat(parent.as_fd(), name, Mode::RWXU) {
|
||||
Ok(()) => true,
|
||||
Err(rustix::io::Errno::EXIST) if !require_new => false,
|
||||
Err(error) => return Err(failure(error)),
|
||||
};
|
||||
let directory = openat2(
|
||||
parent.as_fd(),
|
||||
name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
if created {
|
||||
fchmod(&directory, Mode::RWXU).map_err(failure)?;
|
||||
}
|
||||
require_private_directory(&directory, invoking_uid)?;
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn require_private_directory(
|
||||
descriptor: impl AsFd,
|
||||
invoking_uid: u32,
|
||||
) -> Result<(), ColdStartError> {
|
||||
let metadata = fstat(descriptor).map_err(failure)?;
|
||||
if !FileType::from_raw_mode(metadata.st_mode).is_dir()
|
||||
|| metadata.st_uid != invoking_uid
|
||||
|| metadata.st_mode & 0o7777 != 0o700
|
||||
{
|
||||
return Err(ColdStartError(
|
||||
"runtime staging directory is not invoking-user-owned mode 0700".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_private_file(
|
||||
descriptor: impl AsFd,
|
||||
invoking_uid: u32,
|
||||
mode: u32,
|
||||
) -> Result<(), ColdStartError> {
|
||||
let metadata = fstat(descriptor).map_err(failure)?;
|
||||
if !FileType::from_raw_mode(metadata.st_mode).is_file()
|
||||
|| metadata.st_uid != invoking_uid
|
||||
|| metadata.st_mode & 0o7777 != mode
|
||||
|| metadata.st_nlink != 1
|
||||
{
|
||||
return Err(ColdStartError(
|
||||
"runtime staging file has unsafe type, ownership, mode, or link count".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_staging_directory(
|
||||
parent: impl AsFd,
|
||||
name: &std::ffi::OsStr,
|
||||
directory: impl AsFd,
|
||||
expected: FileIdentity,
|
||||
) {
|
||||
let retained = fstat(directory.as_fd());
|
||||
if !retained
|
||||
.as_ref()
|
||||
.is_ok_and(|metadata| expected.matches(metadata))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if remove_children(directory.as_fd()).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(current) = statat(parent.as_fd(), name, AtFlags::SYMLINK_NOFOLLOW) else {
|
||||
return;
|
||||
};
|
||||
if expected.matches(¤t) && FileType::from_raw_mode(current.st_mode).is_dir() {
|
||||
let _ = unlinkat(parent.as_fd(), name, AtFlags::REMOVEDIR);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_children(directory: impl AsFd) -> std::io::Result<()> {
|
||||
let duplicate = rustix::io::dup(directory.as_fd())?;
|
||||
let mut buffer = [std::mem::MaybeUninit::uninit(); 8192];
|
||||
let names = {
|
||||
let mut names = Vec::new();
|
||||
let mut entries = RawDir::new(duplicate, &mut buffer);
|
||||
while let Some(entry) = entries.next() {
|
||||
let entry = entry?;
|
||||
let bytes = entry.file_name().to_bytes();
|
||||
if bytes != b"." && bytes != b".." {
|
||||
names.push(OsString::from_vec(bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
names
|
||||
};
|
||||
for name in names {
|
||||
let metadata = statat(directory.as_fd(), &name, AtFlags::SYMLINK_NOFOLLOW)?;
|
||||
if FileType::from_raw_mode(metadata.st_mode).is_dir() {
|
||||
let child = openat2(
|
||||
directory.as_fd(),
|
||||
&name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
confined_resolve_flags(),
|
||||
)?;
|
||||
remove_children(&child)?;
|
||||
unlinkat(directory.as_fd(), &name, AtFlags::REMOVEDIR)?;
|
||||
} else {
|
||||
unlinkat(directory.as_fd(), &name, AtFlags::empty())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn confined_resolve_flags() -> ResolveFlags {
|
||||
ResolveFlags::BENEATH
|
||||
| ResolveFlags::NO_MAGICLINKS
|
||||
| ResolveFlags::NO_SYMLINKS
|
||||
| ResolveFlags::NO_XDEV
|
||||
}
|
||||
|
||||
fn host_path(bytes: &[u8]) -> PathBuf {
|
||||
PathBuf::from(OsString::from_vec(bytes.to_vec()))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
bytes.iter().fold(String::new(), |mut output, byte| {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
36
crates/lazaret-host-coordinator/src/cold_start/tests.rs
Normal file
36
crates/lazaret-host-coordinator/src/cold_start/tests.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs::File;
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
use rustix::pipe::{PipeFlags, pipe_with};
|
||||
|
||||
use super::BootstrapOwnerGuard;
|
||||
|
||||
#[test]
|
||||
fn bootstrap_owner_guard_tracks_the_exact_inherited_pipe() {
|
||||
let (reader, writer) = pipe_with(PipeFlags::CLOEXEC).unwrap();
|
||||
let guard = BootstrapOwnerGuard::from_inherited(reader).unwrap();
|
||||
assert!(guard.is_live().unwrap());
|
||||
drop(writer);
|
||||
assert!(!guard.is_live().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_owner_guard_refuses_non_pipe_authority() {
|
||||
let descriptor: OwnedFd = File::open("/dev/null").unwrap().into();
|
||||
assert!(BootstrapOwnerGuard::from_inherited(descriptor).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_relative_global_excludes_are_workspace_relative() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let workspace = root.path().join("workspace");
|
||||
|
||||
assert_eq!(
|
||||
super::parse_global_excludes_path(&workspace, b"policy/ignore\n").unwrap(),
|
||||
Some(workspace.join("policy/ignore"))
|
||||
);
|
||||
assert!(super::parse_global_excludes_path(&workspace, b"bad\0path\n").is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
354
crates/lazaret-host-coordinator/src/control/execution.rs
Normal file
354
crates/lazaret-host-coordinator/src/control/execution.rs
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
struct ControlPaths {
|
||||
root: PathBuf,
|
||||
root_file: File,
|
||||
root_identity: FileIdentity,
|
||||
socket: PathBuf,
|
||||
identity: PathBuf,
|
||||
staged_socket: PathBuf,
|
||||
staged_identity: PathBuf,
|
||||
}
|
||||
|
||||
impl ControlPaths {
|
||||
fn open(root: &Path, uid: u32) -> Result<Self, ControlError> {
|
||||
if !root.is_absolute() {
|
||||
return Err(ControlError::InvalidPath(
|
||||
"runtime Domain root is not absolute",
|
||||
));
|
||||
}
|
||||
let canonical = std::fs::canonicalize(root)?;
|
||||
if canonical != root {
|
||||
return Err(ControlError::InvalidPath(
|
||||
"runtime Domain root contains a symlink",
|
||||
));
|
||||
}
|
||||
let metadata = std::fs::symlink_metadata(root)?;
|
||||
if !metadata.file_type().is_dir()
|
||||
|| metadata.uid() != uid
|
||||
|| metadata.mode() & 0o7777 != 0o700
|
||||
{
|
||||
return Err(ControlError::InvalidPath(
|
||||
"runtime Domain root is not invoking-user-owned mode 0700",
|
||||
));
|
||||
}
|
||||
let root_file = OpenOptions::new().read(true).open(root)?;
|
||||
let root_identity = FileIdentity::from_metadata(&root_file.metadata()?);
|
||||
Ok(Self {
|
||||
root: root.to_path_buf(),
|
||||
root_file,
|
||||
root_identity,
|
||||
socket: root.join(CONTROL_SOCKET_NAME),
|
||||
identity: root.join(PUBLICATION_IDENTITY_NAME),
|
||||
staged_socket: root.join(STAGED_CONTROL_SOCKET_NAME),
|
||||
staged_identity: root.join(STAGED_PUBLICATION_IDENTITY_NAME),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_root(&self, uid: u32) -> Result<(), ControlError> {
|
||||
let descriptor = self.root_file.metadata()?;
|
||||
let current = std::fs::symlink_metadata(&self.root)?;
|
||||
if FileIdentity::from_metadata(&descriptor) != self.root_identity
|
||||
|| FileIdentity::from_metadata(¤t) != self.root_identity
|
||||
|| !current.file_type().is_dir()
|
||||
|| current.uid() != uid
|
||||
|| current.mode() & 0o7777 != 0o700
|
||||
{
|
||||
return Err(ControlError::InvalidPath("runtime Domain root changed"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_named_identity(
|
||||
&self,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
uid: u32,
|
||||
) -> Result<(File, FileIdentity), ControlError> {
|
||||
let fd = openat(
|
||||
self.root_file.as_fd(),
|
||||
name,
|
||||
OFlags::CREATE | OFlags::EXCL | OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::RUSR | Mode::WUSR,
|
||||
)
|
||||
.map_err(io::Error::from)?;
|
||||
let mut file = File::from(fd);
|
||||
let identity = FileIdentity::from_metadata(&file.metadata()?);
|
||||
let result = (|| {
|
||||
fchmod(file.as_fd(), Mode::RUSR | Mode::WUSR).map_err(io::Error::from)?;
|
||||
file.write_all(bytes)
|
||||
.map_err(|_| ControlError::InvalidPublication("identity write failed"))?;
|
||||
file.sync_all()
|
||||
.map_err(|_| ControlError::InvalidPublication("identity sync failed"))?;
|
||||
self.root_file
|
||||
.sync_all()
|
||||
.map_err(|_| ControlError::InvalidPublication("directory sync failed"))?;
|
||||
self.validate_identity_at(&self.root.join(name), &file, identity, uid, bytes.len())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
remove_if_identity_matches(&self.root.join(name), identity);
|
||||
return Err(error);
|
||||
}
|
||||
Ok((file, identity))
|
||||
}
|
||||
|
||||
fn validate_identity(
|
||||
&self,
|
||||
file: &File,
|
||||
expected: FileIdentity,
|
||||
uid: u32,
|
||||
expected_length: usize,
|
||||
) -> Result<(), ControlError> {
|
||||
self.validate_identity_at(&self.identity, file, expected, uid, expected_length)
|
||||
}
|
||||
|
||||
fn validate_identity_at(
|
||||
&self,
|
||||
path: &Path,
|
||||
file: &File,
|
||||
expected: FileIdentity,
|
||||
uid: u32,
|
||||
expected_length: usize,
|
||||
) -> Result<(), ControlError> {
|
||||
self.validate_root(uid)?;
|
||||
let descriptor = file.metadata()?;
|
||||
let current = std::fs::symlink_metadata(path)?;
|
||||
if FileIdentity::from_metadata(&descriptor) != expected
|
||||
|| FileIdentity::from_metadata(¤t) != expected
|
||||
|| !descriptor.file_type().is_file()
|
||||
|| !current.file_type().is_file()
|
||||
|| descriptor.uid() != uid
|
||||
|| current.uid() != uid
|
||||
|| descriptor.mode() & 0o7777 != 0o600
|
||||
|| current.mode() & 0o7777 != 0o600
|
||||
|| usize::try_from(descriptor.len()).ok() != Some(expected_length)
|
||||
|| current.len() != descriptor.len()
|
||||
{
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"publication identity changed type, ownership, mode, or content length",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PublishedControlFace {
|
||||
listener: std::os::unix::net::UnixListener,
|
||||
paths: ControlPaths,
|
||||
socket_identity: FileIdentity,
|
||||
identity_file: File,
|
||||
identity_file_identity: FileIdentity,
|
||||
publication_identity: ProtocolPublicationIdentity,
|
||||
publication_bytes: Vec<u8>,
|
||||
uid: u32,
|
||||
anchor: TrustAnchor,
|
||||
server_config: Arc<ServerConfig>,
|
||||
registry: Arc<SessionRegistry>,
|
||||
executor: Arc<dyn NonInteractiveSessionExecutor>,
|
||||
interactive_executor: Arc<dyn InteractiveSessionExecutor>,
|
||||
}
|
||||
|
||||
pub(crate) struct AcceptedControlConnection {
|
||||
socket: std::os::unix::net::UnixStream,
|
||||
}
|
||||
|
||||
impl AcceptedControlConnection {
|
||||
pub(crate) fn cancellation_handle(&self) -> std::io::Result<std::os::unix::net::UnixStream> {
|
||||
self.socket.try_clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NonInteractiveSessionExecutor: Send + Sync {
|
||||
/// # Errors
|
||||
/// Returns a runtime, stream, or exact-session execution failure.
|
||||
fn execute(
|
||||
&self,
|
||||
request: &NonInteractiveSessionRequest,
|
||||
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
|
||||
) -> Result<SessionExit, ControlError>;
|
||||
}
|
||||
|
||||
struct ExecutionUnavailable;
|
||||
|
||||
impl NonInteractiveSessionExecutor for ExecutionUnavailable {
|
||||
fn execute(
|
||||
&self,
|
||||
_request: &NonInteractiveSessionRequest,
|
||||
_emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
|
||||
) -> Result<SessionExit, ControlError> {
|
||||
Err(ControlError::InvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait InteractiveControlSession: Send {
|
||||
/// # Errors
|
||||
/// Returns an exact-session terminal, prompt-routing, or transport failure.
|
||||
fn exchange(
|
||||
&mut self,
|
||||
event: InteractiveSessionEvent,
|
||||
) -> Result<InteractiveSessionBatch, ControlError>;
|
||||
}
|
||||
|
||||
pub trait InteractiveSessionExecutor: Send + Sync {
|
||||
/// # Errors
|
||||
/// Returns an acquisition or exact-session identity failure.
|
||||
fn start(
|
||||
&self,
|
||||
request: &InteractiveSessionRequest,
|
||||
) -> Result<Box<dyn InteractiveControlSession>, ControlError>;
|
||||
}
|
||||
|
||||
struct InteractiveExecutionUnavailable;
|
||||
|
||||
impl InteractiveSessionExecutor for InteractiveExecutionUnavailable {
|
||||
fn start(
|
||||
&self,
|
||||
_request: &InteractiveSessionRequest,
|
||||
) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
|
||||
Err(ControlError::InvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RunscNonInteractiveExecutor {
|
||||
program: OsString,
|
||||
global_arguments: Vec<OsString>,
|
||||
container: OsString,
|
||||
runner: Arc<dyn NonInteractiveCommandRunner>,
|
||||
}
|
||||
|
||||
trait NonInteractiveCommandRunner: Send + Sync {
|
||||
fn run(
|
||||
&self,
|
||||
target: &TargetCommand,
|
||||
session_id: SessionId,
|
||||
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
|
||||
) -> Result<ProcessExit, ControlError>;
|
||||
}
|
||||
|
||||
struct ChildBrokerCommandRunner;
|
||||
|
||||
impl NonInteractiveCommandRunner for ChildBrokerCommandRunner {
|
||||
fn run(
|
||||
&self,
|
||||
target: &TargetCommand,
|
||||
session_id: SessionId,
|
||||
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
|
||||
) -> Result<ProcessExit, ControlError> {
|
||||
let mut child = child_broker().run_transient_captured(target)?;
|
||||
let mut stdout_done = false;
|
||||
let mut stderr_done = false;
|
||||
while !(stdout_done && stderr_done && child.process().has_exited()?) {
|
||||
let (stdout, stderr) = child.streams();
|
||||
stdout_done |= emit_available(stdout, session_id, true, emit)?;
|
||||
stderr_done |= emit_available(stderr, session_id, false, emit)?;
|
||||
if !(stdout_done && stderr_done) {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
child_broker()
|
||||
.reap_status(child.into_process())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl RunscNonInteractiveExecutor {
|
||||
/// Derives the exact `runsc exec` prefix from the generation's attached `runsc run` command.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a non-runsc command or a command without one exact run/container suffix.
|
||||
pub fn from_run_command(
|
||||
command: &RunscCommand,
|
||||
container_id: ContainerId,
|
||||
) -> Result<Self, ControlError> {
|
||||
if command.program() != OsStr::new("runsc") {
|
||||
return Err(ControlError::InvalidRequest);
|
||||
}
|
||||
let run = command
|
||||
.arguments()
|
||||
.iter()
|
||||
.position(|argument| argument == OsStr::new("run"))
|
||||
.ok_or(ControlError::InvalidRequest)?;
|
||||
if run + 3 != command.arguments().len()
|
||||
|| !command.arguments()[run + 1]
|
||||
.as_encoded_bytes()
|
||||
.starts_with(b"-bundle=")
|
||||
{
|
||||
return Err(ControlError::InvalidRequest);
|
||||
}
|
||||
let container = OsString::from(hex_lower(container_id.as_bytes()));
|
||||
if command.arguments()[run + 2] != container {
|
||||
return Err(ControlError::InvalidRequest);
|
||||
}
|
||||
Ok(Self {
|
||||
program: command.program().to_owned(),
|
||||
global_arguments: command.arguments()[..run].to_vec(),
|
||||
container,
|
||||
runner: Arc::new(ChildBrokerCommandRunner),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl NonInteractiveSessionExecutor for RunscNonInteractiveExecutor {
|
||||
fn execute(
|
||||
&self,
|
||||
request: &NonInteractiveSessionRequest,
|
||||
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
|
||||
) -> Result<SessionExit, ControlError> {
|
||||
let mut target = TargetCommand::new(self.program.clone());
|
||||
for argument in &self.global_arguments {
|
||||
target = target.argument(argument.clone());
|
||||
}
|
||||
target = target
|
||||
.argument("exec")
|
||||
.argument("-env")
|
||||
.argument(format!(
|
||||
"LAZARET_SESSION_ID={}",
|
||||
hex_lower(request.session_id.as_bytes())
|
||||
))
|
||||
.argument(self.container.clone());
|
||||
for argument in &request.argv {
|
||||
target = target.argument(OsString::from_vec(argument.clone()));
|
||||
}
|
||||
let process_status = self.runner.run(&target, request.session_id, emit)?;
|
||||
match (process_status.exit_code, process_status.terminating_signal) {
|
||||
(Some(code), None) => Ok(SessionExit::exited(request.session_id, code)),
|
||||
(None, Some(_)) => Ok(SessionExit::terminated(request.session_id)),
|
||||
_ => Err(ControlError::InvalidRequest),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_available(
|
||||
stream: &mut File,
|
||||
session_id: SessionId,
|
||||
stdout: bool,
|
||||
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
|
||||
) -> Result<bool, ControlError> {
|
||||
let mut bytes = [0_u8; 16 * 1024];
|
||||
loop {
|
||||
match stream.read(&mut bytes) {
|
||||
Ok(0) => return Ok(true),
|
||||
Ok(length) => {
|
||||
let output = lazaret_core::records::NonInteractiveOutput {
|
||||
session_id,
|
||||
bytes: bytes[..length].to_vec(),
|
||||
};
|
||||
emit(if stdout {
|
||||
NonInteractiveSessionMessage::Stdout(output)
|
||||
} else {
|
||||
NonInteractiveSessionMessage::Stderr(output)
|
||||
})?;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(false),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
use fmt::Write as _;
|
||||
|
||||
bytes.iter().fold(String::new(), |mut output, byte| {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
772
crates/lazaret-host-coordinator/src/control/publication.rs
Normal file
772
crates/lazaret-host-coordinator/src/control/publication.rs
Normal file
|
|
@ -0,0 +1,772 @@
|
|||
impl PublishedControlFace {
|
||||
pub(crate) fn recovery_ownership(&self) -> Result<PublicationOwnership, ControlError> {
|
||||
publication_recovery_ownership(&self.paths)
|
||||
}
|
||||
|
||||
/// Selects blocking or polling admission for the publication listener.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an operating-system failure changing the listener mode.
|
||||
pub fn set_nonblocking(&self, nonblocking: bool) -> Result<(), ControlError> {
|
||||
self.listener.set_nonblocking(nonblocking)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discovers one publication through the retained runtime Domain authority.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses changed root authority or absent, replaced, non-private, oversized, or malformed
|
||||
/// publication objects.
|
||||
pub fn discover_confined(
|
||||
runtime_domain_root: &ConfinedPathPlan,
|
||||
uid: u32,
|
||||
) -> Result<(File, PathBuf, ProtocolPublicationIdentity), ControlError> {
|
||||
let root = File::from(
|
||||
runtime_domain_root
|
||||
.open_authority()
|
||||
.map_err(|_| ControlError::InvalidPath("runtime Domain authority changed"))?,
|
||||
);
|
||||
let root_metadata = root.metadata()?;
|
||||
if !root_metadata.is_dir()
|
||||
|| root_metadata.uid() != uid
|
||||
|| root_metadata.mode() & 0o7777 != 0o700
|
||||
{
|
||||
return Err(ControlError::InvalidPath(
|
||||
"runtime Domain root is not invoking-user-owned mode 0700",
|
||||
));
|
||||
}
|
||||
let descriptor = openat(
|
||||
root.as_fd(),
|
||||
PUBLICATION_IDENTITY_NAME,
|
||||
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
)
|
||||
.map_err(io::Error::from)?;
|
||||
let mut identity = File::from(descriptor);
|
||||
let identity_metadata = identity.metadata()?;
|
||||
if !identity_metadata.file_type().is_file()
|
||||
|| identity_metadata.uid() != uid
|
||||
|| identity_metadata.mode() & 0o7777 != 0o600
|
||||
|| identity_metadata.nlink() != 1
|
||||
|| identity_metadata.len() > MAX_CONTROL_BYTES as u64
|
||||
{
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"publication identity is not an exact private regular file",
|
||||
));
|
||||
}
|
||||
let socket = File::from(
|
||||
openat(
|
||||
root.as_fd(),
|
||||
CONTROL_SOCKET_NAME,
|
||||
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
)
|
||||
.map_err(io::Error::from)?,
|
||||
);
|
||||
let socket_metadata = socket.metadata()?;
|
||||
if !socket_metadata.file_type().is_socket()
|
||||
|| socket_metadata.uid() != uid
|
||||
|| socket_metadata.mode() & 0o7777 != 0o600
|
||||
{
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"control socket is not an invoking-user-private socket",
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
identity.read_to_end(&mut bytes)?;
|
||||
let socket_path = PathBuf::from(format!(
|
||||
"/proc/self/fd/{}/{}",
|
||||
root.as_raw_fd(),
|
||||
CONTROL_SOCKET_NAME
|
||||
));
|
||||
Ok((root, socket_path, decode(&bytes)?))
|
||||
}
|
||||
|
||||
/// Discovers one exact current control publication beneath a validated runtime Domain root.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses absent, replaced, non-private, oversized, or malformed publication objects.
|
||||
pub fn discover(
|
||||
runtime_domain_root: &Path,
|
||||
uid: u32,
|
||||
) -> Result<(PathBuf, ProtocolPublicationIdentity), ControlError> {
|
||||
let paths = ControlPaths::open(runtime_domain_root, uid)?;
|
||||
let descriptor = openat(
|
||||
paths.root_file.as_fd(),
|
||||
PUBLICATION_IDENTITY_NAME,
|
||||
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
)
|
||||
.map_err(io::Error::from)?;
|
||||
let mut identity = File::from(descriptor);
|
||||
let descriptor_metadata = identity.metadata()?;
|
||||
let path_metadata = std::fs::symlink_metadata(&paths.identity)?;
|
||||
let expected = FileIdentity::from_metadata(&descriptor_metadata);
|
||||
if FileIdentity::from_metadata(&path_metadata) != expected
|
||||
|| !descriptor_metadata.file_type().is_file()
|
||||
|| descriptor_metadata.uid() != uid
|
||||
|| descriptor_metadata.mode() & 0o7777 != 0o600
|
||||
|| descriptor_metadata.nlink() != 1
|
||||
|| descriptor_metadata.len() > MAX_CONTROL_BYTES as u64
|
||||
{
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"publication identity is not an exact private regular file",
|
||||
));
|
||||
}
|
||||
let socket = std::fs::symlink_metadata(&paths.socket)?;
|
||||
if !socket.file_type().is_socket() || socket.uid() != uid || socket.mode() & 0o7777 != 0o600
|
||||
{
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"control socket is not an invoking-user-private socket",
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
identity.read_to_end(&mut bytes)?;
|
||||
Ok((paths.socket, decode(&bytes)?))
|
||||
}
|
||||
|
||||
/// Publishes one generation control face in the fixed host-private runtime path.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses stale generations, existing publication objects, path/ownership/mode errors, and
|
||||
/// TLS configuration failures.
|
||||
pub fn publish(
|
||||
runtime_domain_root: &Path,
|
||||
uid: u32,
|
||||
anchor: TrustAnchor,
|
||||
store: Arc<HostCoordinatorDomainStore>,
|
||||
) -> Result<Self, ControlError> {
|
||||
Self::publish_with_executor(
|
||||
runtime_domain_root,
|
||||
uid,
|
||||
anchor,
|
||||
store,
|
||||
Arc::new(ExecutionUnavailable),
|
||||
)
|
||||
}
|
||||
|
||||
/// Publishes one generation control face with non-interactive session execution.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses the same invalid publication conditions as [`Self::publish`].
|
||||
pub fn publish_with_executor(
|
||||
runtime_domain_root: &Path,
|
||||
uid: u32,
|
||||
anchor: TrustAnchor,
|
||||
store: Arc<HostCoordinatorDomainStore>,
|
||||
executor: Arc<dyn NonInteractiveSessionExecutor>,
|
||||
) -> Result<Self, ControlError> {
|
||||
let registry = Arc::new(SessionRegistry::new(
|
||||
store,
|
||||
anchor.generation_identity.clone(),
|
||||
));
|
||||
StagedControlFace::stage_with_executor(
|
||||
runtime_domain_root,
|
||||
uid,
|
||||
anchor,
|
||||
registry,
|
||||
executor,
|
||||
)?
|
||||
.expose()
|
||||
}
|
||||
|
||||
/// Publishes one generation control face with command and terminal execution.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses the same invalid publication conditions as [`Self::publish`].
|
||||
pub fn publish_with_executors(
|
||||
runtime_domain_root: &Path,
|
||||
uid: u32,
|
||||
anchor: TrustAnchor,
|
||||
store: Arc<HostCoordinatorDomainStore>,
|
||||
executor: Arc<dyn NonInteractiveSessionExecutor>,
|
||||
interactive_executor: Arc<dyn InteractiveSessionExecutor>,
|
||||
) -> Result<Self, ControlError> {
|
||||
let registry = Arc::new(SessionRegistry::new(
|
||||
store,
|
||||
anchor.generation_identity.clone(),
|
||||
));
|
||||
StagedControlFace::stage_with_executors(
|
||||
runtime_domain_root,
|
||||
uid,
|
||||
anchor,
|
||||
registry,
|
||||
executor,
|
||||
interactive_executor,
|
||||
)?
|
||||
.expose()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn publication_identity(&self) -> &ProtocolPublicationIdentity {
|
||||
&self.publication_identity
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn registry(&self) -> Arc<SessionRegistry> {
|
||||
Arc::clone(&self.registry)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
&self.paths.socket
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn identity_path(&self) -> &Path {
|
||||
&self.paths.identity
|
||||
}
|
||||
|
||||
/// Withdraws exact discovery and connection paths while retaining live accepted streams.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses changed artifacts, removal failures, or artifacts that remain after removal.
|
||||
pub fn withdraw(&self) -> Result<(), ControlError> {
|
||||
remove_exact_if_identity_matches(&self.paths.socket, self.socket_identity)?;
|
||||
remove_exact_if_identity_matches(&self.paths.identity, self.identity_file_identity)
|
||||
}
|
||||
|
||||
/// Accepts and serves one TLS connection. Registered connections remain live until EOF.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses peer UID, publication, TLS, ALPN, certificate, framing, tuple, duplicate, and
|
||||
/// persistence failures.
|
||||
pub fn serve_next(&self) -> Result<(), ControlError> {
|
||||
self.serve_next_coordinated(
|
||||
|admission| {
|
||||
self.registry
|
||||
.register(admission.request, &admission.credential)
|
||||
.map(|_| ())
|
||||
},
|
||||
|session_id| self.registry.disconnect(session_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Serves one connection while routing authenticated session state through its lifecycle.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses the same conditions as [`Self::serve_next`] plus lifecycle admission or disconnect
|
||||
/// failure.
|
||||
pub fn serve_next_coordinated<Admit, Disconnect>(
|
||||
&self,
|
||||
admit: Admit,
|
||||
disconnect: Disconnect,
|
||||
) -> Result<(), ControlError>
|
||||
where
|
||||
Admit: FnMut(ControlSessionAdmission) -> Result<(), ControlError>,
|
||||
Disconnect: FnMut(SessionId) -> Result<(), ControlError>,
|
||||
{
|
||||
self.serve_next_coordinated_with_registration(admit, |_, _| Ok(()), disconnect)
|
||||
}
|
||||
|
||||
pub(crate) fn serve_next_coordinated_with_registration<Admit, Registered, Disconnect>(
|
||||
&self,
|
||||
admit: Admit,
|
||||
registered: Registered,
|
||||
disconnect: Disconnect,
|
||||
) -> Result<(), ControlError>
|
||||
where
|
||||
Admit: FnMut(ControlSessionAdmission) -> Result<(), ControlError>,
|
||||
Registered: FnMut(SessionBinding, SessionRegistrationResponse) -> Result<(), ControlError>,
|
||||
Disconnect: FnMut(SessionId) -> Result<(), ControlError>,
|
||||
{
|
||||
let connection = self.accept_connection()?;
|
||||
self.serve_connection_coordinated(connection, admit, registered, disconnect)
|
||||
}
|
||||
|
||||
pub(crate) fn accept_connection(&self) -> Result<AcceptedControlConnection, ControlError> {
|
||||
let (socket, _) = self.listener.accept()?;
|
||||
Ok(AcceptedControlConnection { socket })
|
||||
}
|
||||
|
||||
pub(crate) fn serve_connection_coordinated<Admit, Registered, Disconnect>(
|
||||
&self,
|
||||
connection: AcceptedControlConnection,
|
||||
mut admit: Admit,
|
||||
mut registered: Registered,
|
||||
mut disconnect: Disconnect,
|
||||
) -> Result<(), ControlError>
|
||||
where
|
||||
Admit: FnMut(ControlSessionAdmission) -> Result<(), ControlError>,
|
||||
Registered: FnMut(SessionBinding, SessionRegistrationResponse) -> Result<(), ControlError>,
|
||||
Disconnect: FnMut(SessionId) -> Result<(), ControlError>,
|
||||
{
|
||||
let socket = connection.socket;
|
||||
let peer = socket_peercred(&socket).map_err(io::Error::from)?;
|
||||
require_peer_uid(peer.uid.as_raw(), self.uid)?;
|
||||
self.validate_publication()?;
|
||||
let mut connection = ServerConnection::new(Arc::clone(&self.server_config))?;
|
||||
let mut socket = socket;
|
||||
while connection.is_handshaking() {
|
||||
connection.complete_io(&mut socket)?;
|
||||
}
|
||||
if connection.alpn_protocol() != Some(CONTROL_ALPN) {
|
||||
return Err(ControlError::InvalidRequest);
|
||||
}
|
||||
let peer_certificate = connection.peer_certificates().map(|certificates| {
|
||||
certificates
|
||||
.iter()
|
||||
.map(|certificate| certificate.to_vec())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let mut stream = rustls::StreamOwned::new(connection, socket);
|
||||
match peer_certificate {
|
||||
None => self.serve_enrollment(&mut stream),
|
||||
Some(certificates) => self.serve_registration(
|
||||
&mut stream,
|
||||
&certificates,
|
||||
&mut admit,
|
||||
&mut registered,
|
||||
&mut disconnect,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_enrollment(
|
||||
&self,
|
||||
stream: &mut rustls::StreamOwned<ServerConnection, std::os::unix::net::UnixStream>,
|
||||
) -> Result<(), ControlError> {
|
||||
let request: ControlRequest = read_frame(stream, MAX_ENROLLMENT_BYTES)?;
|
||||
let ControlRequest::Enroll(request) = request else {
|
||||
return Err(ControlError::InvalidRequest);
|
||||
};
|
||||
let credential = self.anchor.issue_client_credential(&request)?;
|
||||
self.registry.enroll(credential.clone())?;
|
||||
write_frame(
|
||||
stream,
|
||||
&ControlResponse::Enrolled(ClientEnrollmentResponse {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
credential,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn serve_registration<Admit, Registered, Disconnect>(
|
||||
&self,
|
||||
stream: &mut rustls::StreamOwned<ServerConnection, std::os::unix::net::UnixStream>,
|
||||
certificates: &[Vec<u8>],
|
||||
admit: &mut Admit,
|
||||
registered: &mut Registered,
|
||||
disconnect: &mut Disconnect,
|
||||
) -> Result<(), ControlError>
|
||||
where
|
||||
Admit: FnMut(ControlSessionAdmission) -> Result<(), ControlError>,
|
||||
Registered: FnMut(SessionBinding, SessionRegistrationResponse) -> Result<(), ControlError>,
|
||||
Disconnect: FnMut(SessionId) -> Result<(), ControlError>,
|
||||
{
|
||||
let [leaf] = certificates else {
|
||||
return Err(ControlError::InvalidCertificateProfile);
|
||||
};
|
||||
let credential = self.registry.credential_for_leaf(leaf)?;
|
||||
let request: ControlRequest = read_frame(stream, MAX_CONTROL_BYTES)?;
|
||||
let ControlRequest::Register(request) = request else {
|
||||
return Err(ControlError::InvalidRequest);
|
||||
};
|
||||
let capability = issue_session_capability(
|
||||
request.session_binding.clone(),
|
||||
self.anchor.coordinator_protocol_credential.credential_id,
|
||||
self.anchor.sandbox_protocol_credential.credential_id,
|
||||
&self.anchor.coordinator_protocol_key,
|
||||
)?;
|
||||
let session_id = request.session_binding.session_id;
|
||||
let binding = request.session_binding.clone();
|
||||
admit(ControlSessionAdmission {
|
||||
request,
|
||||
credential,
|
||||
})?;
|
||||
let result = (|| {
|
||||
let registration = SessionRegistrationResponse {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_capability: capability,
|
||||
};
|
||||
registered(binding, registration.clone())?;
|
||||
write_frame(stream, &ControlResponse::Registered(registration))?;
|
||||
let request: ControlRequest = match read_frame(stream, MAX_CONTROL_BYTES) {
|
||||
Ok(request) => request,
|
||||
Err(ControlError::Io(error))
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset
|
||||
) =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
match request {
|
||||
ControlRequest::Execute(request) => {
|
||||
validate_command(request.session_id, session_id, &request.argv)?;
|
||||
let mut send_message = |message: NonInteractiveSessionMessage| {
|
||||
if matches!(message, NonInteractiveSessionMessage::Exit(_)) {
|
||||
return Err(ControlError::InvalidRequest);
|
||||
}
|
||||
require_non_interactive_session(&message, request.session_id)?;
|
||||
write_frame(stream, &message)
|
||||
};
|
||||
let outcome = self.executor.execute(&request, &mut send_message)?;
|
||||
require_session_id(outcome.session_id(), request.session_id)?;
|
||||
write_frame(stream, &NonInteractiveSessionMessage::Exit(outcome))
|
||||
}
|
||||
ControlRequest::ExecuteInteractive(request) => {
|
||||
validate_command(request.session_id, session_id, &request.argv)?;
|
||||
if request.initial_size.session_id != session_id {
|
||||
return Err(ControlError::InvalidRequest);
|
||||
}
|
||||
let mut terminal = self.interactive_executor.start(&request)?;
|
||||
let mut event = InteractiveSessionEvent::Poll { session_id };
|
||||
loop {
|
||||
let batch = terminal.exchange(event)?;
|
||||
require_interactive_batch(&batch, session_id)?;
|
||||
let complete = batch.messages.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
InteractiveSessionMessage::Terminal(
|
||||
lazaret_core::records::SessionTerminalMessage::Exit(_)
|
||||
)
|
||||
)
|
||||
});
|
||||
write_frame(stream, &batch)?;
|
||||
if complete {
|
||||
break Ok(());
|
||||
}
|
||||
event = read_frame(stream, MAX_CONTROL_BYTES)?;
|
||||
require_interactive_event(&event, session_id)?;
|
||||
}
|
||||
}
|
||||
ControlRequest::Enroll(_) | ControlRequest::Register(_) => {
|
||||
Err(ControlError::InvalidRequest)
|
||||
}
|
||||
}
|
||||
})();
|
||||
let disconnected = disconnect(session_id);
|
||||
result.and(disconnected)
|
||||
}
|
||||
|
||||
fn validate_publication(&self) -> Result<(), ControlError> {
|
||||
self.paths.validate_root(self.uid)?;
|
||||
let socket = std::fs::symlink_metadata(&self.paths.socket)?;
|
||||
let identity = std::fs::symlink_metadata(&self.paths.identity)?;
|
||||
if FileIdentity::from_metadata(&socket) != self.socket_identity
|
||||
|| !socket.file_type().is_socket()
|
||||
|| socket.uid() != self.uid
|
||||
|| socket.mode() & 0o7777 != 0o600
|
||||
|| FileIdentity::from_metadata(&identity) != self.identity_file_identity
|
||||
|| !identity.file_type().is_file()
|
||||
|| identity.uid() != self.uid
|
||||
|| identity.mode() & 0o7777 != 0o600
|
||||
|| usize::try_from(self.identity_file.metadata()?.len()).ok()
|
||||
!= Some(self.publication_bytes.len())
|
||||
{
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"publication objects changed identity, ownership, or mode",
|
||||
));
|
||||
}
|
||||
let mut bytes = vec![0; self.publication_bytes.len()];
|
||||
self.identity_file.read_exact_at(&mut bytes, 0)?;
|
||||
if bytes != self.publication_bytes {
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"publication identity bytes changed",
|
||||
));
|
||||
}
|
||||
self.registry.require_serviceable_generation()
|
||||
}
|
||||
|
||||
/// Creates a TLS client configuration pinned to this publication identity.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses malformed CA or client certificate/key material.
|
||||
pub fn client_config(
|
||||
publication: &ProtocolPublicationIdentity,
|
||||
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
|
||||
) -> Result<ClientConfig, ControlError> {
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(CertificateDer::from(
|
||||
publication.tls_ca_certificate_der.clone(),
|
||||
))?;
|
||||
let builder = ClientConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.with_root_certificates(roots);
|
||||
let mut config = if let Some((credential, private_key)) = client {
|
||||
verify_tls_credential(
|
||||
credential,
|
||||
&publication.generation_identity,
|
||||
TlsRole::ClientCli,
|
||||
credential.subject_id(),
|
||||
)?;
|
||||
builder.with_client_auth_cert(
|
||||
vec![CertificateDer::from(credential.certificate_der.clone())],
|
||||
private_key,
|
||||
)?
|
||||
} else {
|
||||
builder.with_no_client_auth()
|
||||
};
|
||||
config.alpn_protocols = vec![CONTROL_ALPN.to_vec()];
|
||||
config.enable_early_data = false;
|
||||
config.resumption = rustls::client::Resumption::disabled();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Connects a rustls stream to the fixed control socket using an existing configuration.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns path, connection, or TLS initialization failures.
|
||||
pub fn connect(
|
||||
socket_path: &Path,
|
||||
config: ClientConfig,
|
||||
publication: &ProtocolPublicationIdentity,
|
||||
) -> Result<rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>, ControlError>
|
||||
{
|
||||
let socket = std::os::unix::net::UnixStream::connect(socket_path)?;
|
||||
let server_name = ServerName::try_from(CONTROL_SERVER_NAME)
|
||||
.map_err(|_| ControlError::InvalidCertificateProfile)?;
|
||||
let mut connection = ClientConnection::new(Arc::new(config), server_name)?;
|
||||
let mut socket = socket;
|
||||
while connection.is_handshaking() {
|
||||
connection.complete_io(&mut socket)?;
|
||||
}
|
||||
let [certificate] = connection.peer_certificates().unwrap_or_default() else {
|
||||
return Err(ControlError::InvalidCertificateProfile);
|
||||
};
|
||||
if connection.protocol_version() != Some(rustls::ProtocolVersion::TLSv1_3)
|
||||
|| connection.alpn_protocol() != Some(CONTROL_ALPN)
|
||||
|| certificate_digest(certificate) != publication.coordinator_tls_certificate_sha256
|
||||
{
|
||||
return Err(ControlError::InvalidCertificateProfile);
|
||||
}
|
||||
Ok(rustls::StreamOwned::new(connection, socket))
|
||||
}
|
||||
|
||||
/// Writes one length-delimited control record.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns encoding or TLS I/O failures.
|
||||
pub fn send<Request: serde::Serialize>(
|
||||
stream: &mut impl Write,
|
||||
request: &Request,
|
||||
) -> Result<(), ControlError> {
|
||||
write_frame(stream, request)
|
||||
}
|
||||
|
||||
/// Reads one length-delimited control record.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns framing, size, decoding, or TLS I/O failures.
|
||||
pub fn receive<Response: serde::de::DeserializeOwned + serde::Serialize>(
|
||||
stream: &mut impl Read,
|
||||
) -> Result<Response, ControlError> {
|
||||
read_frame(stream, MAX_CONTROL_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PublishedControlFace {
|
||||
fn drop(&mut self) {
|
||||
remove_if_identity_matches(&self.paths.socket, self.socket_identity);
|
||||
remove_if_identity_matches(&self.paths.identity, self.identity_file_identity);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_exact_if_identity_matches(
|
||||
path: &Path,
|
||||
expected: FileIdentity,
|
||||
) -> Result<(), ControlError> {
|
||||
let metadata = match std::fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if FileIdentity::from_metadata(&metadata) != expected {
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"publication artifact identity changed before withdrawal",
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(path)?;
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
Ok(_) => Err(ControlError::InvalidPublication(
|
||||
"publication artifact remained after withdrawal",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_if_identity_matches(path: &Path, expected: FileIdentity) {
|
||||
if std::fs::symlink_metadata(path)
|
||||
.ok()
|
||||
.is_some_and(|metadata| FileIdentity::from_metadata(&metadata) == expected)
|
||||
{
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn require_non_interactive_session(
|
||||
message: &NonInteractiveSessionMessage,
|
||||
expected: SessionId,
|
||||
) -> Result<(), ControlError> {
|
||||
let actual = match message {
|
||||
NonInteractiveSessionMessage::Stdout(output)
|
||||
| NonInteractiveSessionMessage::Stderr(output) => output.session_id,
|
||||
NonInteractiveSessionMessage::Exit(exit) => exit.session_id(),
|
||||
};
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ControlError::InvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_command(
|
||||
actual: SessionId,
|
||||
expected: SessionId,
|
||||
argv: &[Vec<u8>],
|
||||
) -> Result<(), ControlError> {
|
||||
require_session_id(actual, expected)?;
|
||||
if argv.is_empty() || argv[0].is_empty() || argv.iter().any(|argument| argument.contains(&0)) {
|
||||
return Err(ControlError::InvalidRequest);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_session_id(actual: SessionId, expected: SessionId) -> Result<(), ControlError> {
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ControlError::InvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
fn require_interactive_event(
|
||||
event: &InteractiveSessionEvent,
|
||||
expected: SessionId,
|
||||
) -> Result<(), ControlError> {
|
||||
let actual = match event {
|
||||
InteractiveSessionEvent::Terminal(event) => match event {
|
||||
lazaret_core::records::SessionTerminalEvent::Input(event) => event.session_id,
|
||||
lazaret_core::records::SessionTerminalEvent::Resize(event) => event.session_id,
|
||||
lazaret_core::records::SessionTerminalEvent::Signal(event) => event.session_id,
|
||||
lazaret_core::records::SessionTerminalEvent::Close(event) => event.session_id,
|
||||
},
|
||||
InteractiveSessionEvent::PromptDecision { session_id, .. }
|
||||
| InteractiveSessionEvent::Poll { session_id } => *session_id,
|
||||
};
|
||||
require_session_id(actual, expected)
|
||||
}
|
||||
|
||||
fn require_interactive_batch(
|
||||
batch: &InteractiveSessionBatch,
|
||||
expected: SessionId,
|
||||
) -> Result<(), ControlError> {
|
||||
require_session_id(batch.session_id, expected)?;
|
||||
for message in &batch.messages {
|
||||
let actual = match message {
|
||||
InteractiveSessionMessage::Terminal(message) => match message {
|
||||
lazaret_core::records::SessionTerminalMessage::Output(output) => output.session_id,
|
||||
lazaret_core::records::SessionTerminalMessage::Exit(exit) => exit.session_id(),
|
||||
},
|
||||
InteractiveSessionMessage::Prompt(prompt) => prompt.session_id,
|
||||
};
|
||||
require_session_id(actual, expected)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leaf_parameters(
|
||||
common_name: &str,
|
||||
usage: ExtendedKeyUsagePurpose,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<CertificateParams, ControlError> {
|
||||
let mut params = CertificateParams::new(vec![common_name.to_owned()])?;
|
||||
params.distinguished_name = DistinguishedName::new();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, common_name);
|
||||
params.not_before = now - Duration::minutes(1);
|
||||
params.not_after = now + Duration::hours(1);
|
||||
params.is_ca = IsCa::NoCa;
|
||||
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
|
||||
params.extended_key_usages = vec![usage];
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn random_signing_key() -> Result<SigningKey, ControlError> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
getrandom::fill(&mut bytes).map_err(|_| ControlError::InvalidCertificateProfile)?;
|
||||
Ok(SigningKey::from_bytes(&bytes))
|
||||
}
|
||||
|
||||
fn enrollment_binding(
|
||||
generation_identity: &GenerationIdentity,
|
||||
cli_id: ClientId,
|
||||
) -> Result<String, ControlError> {
|
||||
let encoded = encode(&(generation_identity, cli_id))?;
|
||||
Ok(format!("lazaret-client-{}", hex(&sha256(&encoded))))
|
||||
}
|
||||
|
||||
fn certificate_digest(certificate: impl AsRef<[u8]>) -> CertificateDigest {
|
||||
CertificateDigest::new(sha256(certificate.as_ref()))
|
||||
}
|
||||
|
||||
fn sha256(bytes: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(bytes).into()
|
||||
}
|
||||
|
||||
fn short_identity(bytes: &[u8; 32]) -> String {
|
||||
hex(&bytes[..8])
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
let mut encoded = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
use fmt::Write as _;
|
||||
let _ = write!(encoded, "{byte:02x}");
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn require_peer_uid(actual: u32, expected: u32) -> Result<(), ControlError> {
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ControlError::UnauthorizedPeer)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_live_session(phase: SessionPhase) -> bool {
|
||||
matches!(
|
||||
phase,
|
||||
SessionPhase::Registering
|
||||
| SessionPhase::Attached
|
||||
| SessionPhase::Prompting
|
||||
| SessionPhase::HostExecuting
|
||||
)
|
||||
}
|
||||
|
||||
fn read_frame<Record: serde::de::DeserializeOwned + serde::Serialize>(
|
||||
reader: &mut impl Read,
|
||||
maximum: usize,
|
||||
) -> Result<Record, ControlError> {
|
||||
let mut length = [0_u8; 4];
|
||||
reader.read_exact(&mut length)?;
|
||||
let length = u32::from_be_bytes(length) as usize;
|
||||
if length > maximum {
|
||||
return Err(ControlError::OversizeRequest { length, maximum });
|
||||
}
|
||||
let mut bytes = vec![0_u8; length];
|
||||
reader.read_exact(&mut bytes)?;
|
||||
decode(&bytes).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn write_frame<Record: serde::Serialize>(
|
||||
writer: &mut impl Write,
|
||||
record: &Record,
|
||||
) -> Result<(), ControlError> {
|
||||
let bytes = encode(record)?;
|
||||
let length = u32::try_from(bytes.len())
|
||||
.map_err(|_| ControlError::OversizeRequest {
|
||||
length: bytes.len(),
|
||||
maximum: u32::MAX as usize,
|
||||
})?
|
||||
.to_be_bytes();
|
||||
writer.write_all(&length)?;
|
||||
writer.write_all(&bytes)?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
236
crates/lazaret-host-coordinator/src/control/staging.rs
Normal file
236
crates/lazaret-host-coordinator/src/control/staging.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
pub struct StagedControlFace {
|
||||
listener: Option<std::os::unix::net::UnixListener>,
|
||||
paths: ControlPaths,
|
||||
staged_socket_identity: FileIdentity,
|
||||
identity_file: File,
|
||||
identity_file_identity: FileIdentity,
|
||||
publication_identity: ProtocolPublicationIdentity,
|
||||
publication_bytes: Vec<u8>,
|
||||
uid: u32,
|
||||
anchor: Option<TrustAnchor>,
|
||||
server_config: Arc<ServerConfig>,
|
||||
registry: Arc<SessionRegistry>,
|
||||
executor: Arc<dyn NonInteractiveSessionExecutor>,
|
||||
interactive_executor: Arc<dyn InteractiveSessionExecutor>,
|
||||
}
|
||||
|
||||
impl StagedControlFace {
|
||||
pub(crate) fn recovery_ownership(&self) -> Result<PublicationOwnership, ControlError> {
|
||||
publication_recovery_ownership(&self.paths)
|
||||
}
|
||||
|
||||
/// Stages an undiscoverable private control face before durable publication.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses existing artifacts, changed paths, ownership/mode errors, or TLS failure.
|
||||
pub fn stage(
|
||||
runtime_domain_root: &Path,
|
||||
uid: u32,
|
||||
anchor: TrustAnchor,
|
||||
registry: Arc<SessionRegistry>,
|
||||
) -> Result<Self, ControlError> {
|
||||
Self::stage_with_executor(
|
||||
runtime_domain_root,
|
||||
uid,
|
||||
anchor,
|
||||
registry,
|
||||
Arc::new(ExecutionUnavailable),
|
||||
)
|
||||
}
|
||||
|
||||
/// Stages a control face with its generation-bound non-interactive executor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses the same invalid staging conditions as [`Self::stage`].
|
||||
pub fn stage_with_executor(
|
||||
runtime_domain_root: &Path,
|
||||
uid: u32,
|
||||
anchor: TrustAnchor,
|
||||
registry: Arc<SessionRegistry>,
|
||||
executor: Arc<dyn NonInteractiveSessionExecutor>,
|
||||
) -> Result<Self, ControlError> {
|
||||
Self::stage_with_executors(
|
||||
runtime_domain_root,
|
||||
uid,
|
||||
anchor,
|
||||
registry,
|
||||
executor,
|
||||
Arc::new(InteractiveExecutionUnavailable),
|
||||
)
|
||||
}
|
||||
|
||||
/// Stages a control face with generation-bound command and terminal execution.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses the same invalid staging conditions as [`Self::stage`].
|
||||
pub fn stage_with_executors(
|
||||
runtime_domain_root: &Path,
|
||||
uid: u32,
|
||||
anchor: TrustAnchor,
|
||||
registry: Arc<SessionRegistry>,
|
||||
executor: Arc<dyn NonInteractiveSessionExecutor>,
|
||||
interactive_executor: Arc<dyn InteractiveSessionExecutor>,
|
||||
) -> Result<Self, ControlError> {
|
||||
if registry.generation_identity != anchor.generation_identity {
|
||||
return Err(ControlError::StaleGeneration);
|
||||
}
|
||||
let paths = ControlPaths::open(runtime_domain_root, uid)?;
|
||||
if [
|
||||
&paths.socket,
|
||||
&paths.identity,
|
||||
&paths.staged_socket,
|
||||
&paths.staged_identity,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|path| path.try_exists().unwrap_or(true))
|
||||
{
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"publication path already exists",
|
||||
));
|
||||
}
|
||||
let publication_identity = anchor.publication_identity();
|
||||
let publication_bytes = encode(&publication_identity)?;
|
||||
let server_config = Arc::new(anchor.server_config()?);
|
||||
let (identity_file, identity_file_identity) = paths.create_named_identity(
|
||||
STAGED_PUBLICATION_IDENTITY_NAME,
|
||||
&publication_bytes,
|
||||
uid,
|
||||
)?;
|
||||
let listener = std::os::unix::net::UnixListener::bind(&paths.staged_socket)
|
||||
.map_err(|_| ControlError::InvalidPublication("staged control socket bind failed"))?;
|
||||
std::fs::set_permissions(&paths.staged_socket, std::fs::Permissions::from_mode(0o600))?;
|
||||
let socket_metadata = std::fs::symlink_metadata(&paths.staged_socket)?;
|
||||
let staged_socket_identity = FileIdentity::from_metadata(&socket_metadata);
|
||||
if !socket_metadata.file_type().is_socket()
|
||||
|| socket_metadata.uid() != uid
|
||||
|| socket_metadata.mode() & 0o7777 != 0o600
|
||||
{
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"staged control socket ownership or mode is invalid",
|
||||
));
|
||||
}
|
||||
paths.root_file.sync_all()?;
|
||||
Ok(Self {
|
||||
listener: Some(listener),
|
||||
paths,
|
||||
staged_socket_identity,
|
||||
identity_file,
|
||||
identity_file_identity,
|
||||
publication_identity,
|
||||
publication_bytes,
|
||||
uid,
|
||||
anchor: Some(anchor),
|
||||
server_config,
|
||||
registry,
|
||||
executor,
|
||||
interactive_executor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exposes the staged face after the exact generation is durably Published.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses stale durable state, changed staged artifacts, or exposure failure.
|
||||
pub fn expose(mut self) -> Result<PublishedControlFace, ControlError> {
|
||||
self.registry.require_serviceable_generation()?;
|
||||
self.paths.validate_root(self.uid)?;
|
||||
let staged_socket = std::fs::symlink_metadata(&self.paths.staged_socket)?;
|
||||
if FileIdentity::from_metadata(&staged_socket) != self.staged_socket_identity {
|
||||
return Err(ControlError::InvalidPublication(
|
||||
"staged control socket changed",
|
||||
));
|
||||
}
|
||||
self.paths.validate_identity_at(
|
||||
&self.paths.staged_identity,
|
||||
&self.identity_file,
|
||||
self.identity_file_identity,
|
||||
self.uid,
|
||||
self.publication_bytes.len(),
|
||||
)?;
|
||||
std::fs::rename(&self.paths.staged_socket, &self.paths.socket)?;
|
||||
if let Err(error) = std::fs::rename(&self.paths.staged_identity, &self.paths.identity) {
|
||||
remove_if_identity_matches(&self.paths.socket, self.staged_socket_identity);
|
||||
return Err(error.into());
|
||||
}
|
||||
self.paths.root_file.sync_all()?;
|
||||
let socket_identity =
|
||||
FileIdentity::from_metadata(&std::fs::symlink_metadata(&self.paths.socket)?);
|
||||
self.paths.validate_identity(
|
||||
&self.identity_file,
|
||||
self.identity_file_identity,
|
||||
self.uid,
|
||||
self.publication_bytes.len(),
|
||||
)?;
|
||||
let paths = ControlPaths::open(&self.paths.root, self.uid)?;
|
||||
Ok(PublishedControlFace {
|
||||
listener: self
|
||||
.listener
|
||||
.take()
|
||||
.ok_or(ControlError::InvalidPublication(
|
||||
"staged listener is absent",
|
||||
))?,
|
||||
paths,
|
||||
socket_identity,
|
||||
identity_file: self.identity_file.try_clone()?,
|
||||
identity_file_identity: self.identity_file_identity,
|
||||
publication_identity: self.publication_identity.clone(),
|
||||
publication_bytes: self.publication_bytes.clone(),
|
||||
uid: self.uid,
|
||||
anchor: self.anchor.take().ok_or(ControlError::InvalidPublication(
|
||||
"staged Trust Anchor is absent",
|
||||
))?,
|
||||
server_config: Arc::clone(&self.server_config),
|
||||
registry: Arc::clone(&self.registry),
|
||||
executor: Arc::clone(&self.executor),
|
||||
interactive_executor: Arc::clone(&self.interactive_executor),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StagedControlFace {
|
||||
fn drop(&mut self) {
|
||||
remove_if_identity_matches(&self.paths.staged_socket, self.staged_socket_identity);
|
||||
remove_if_identity_matches(&self.paths.staged_identity, self.identity_file_identity);
|
||||
}
|
||||
}
|
||||
|
||||
fn publication_recovery_ownership(
|
||||
paths: &ControlPaths,
|
||||
) -> Result<PublicationOwnership, ControlError> {
|
||||
let socket_path = if paths.staged_socket.exists() {
|
||||
&paths.staged_socket
|
||||
} else {
|
||||
&paths.socket
|
||||
};
|
||||
let identity_path = if paths.staged_identity.exists() {
|
||||
&paths.staged_identity
|
||||
} else {
|
||||
&paths.identity
|
||||
};
|
||||
Ok(PublicationOwnership {
|
||||
staged_socket_path: Some(HostPath::new(
|
||||
STAGED_CONTROL_SOCKET_NAME.as_bytes().to_vec(),
|
||||
)),
|
||||
staged_identity_path: Some(HostPath::new(
|
||||
STAGED_PUBLICATION_IDENTITY_NAME.as_bytes().to_vec(),
|
||||
)),
|
||||
socket_path: Some(HostPath::new(CONTROL_SOCKET_NAME.as_bytes().to_vec())),
|
||||
identity_path: Some(HostPath::new(PUBLICATION_IDENTITY_NAME.as_bytes().to_vec())),
|
||||
socket_identity: Some(publication_path_identity(paths, socket_path)?),
|
||||
identity_file_identity: Some(publication_path_identity(paths, identity_path)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn publication_path_identity(
|
||||
paths: &ControlPaths,
|
||||
path: &Path,
|
||||
) -> Result<MountIdentity, ControlError> {
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
let root = mount_identity(&paths.root_file)?;
|
||||
Ok(MountIdentity {
|
||||
mount_id: root.mount_id,
|
||||
device: DeviceId::new(metadata.dev()),
|
||||
inode: InodeId::new(metadata.ino()),
|
||||
})
|
||||
}
|
||||
|
||||
193
crates/lazaret-host-coordinator/src/control/tests.rs
Normal file
193
crates/lazaret-host-coordinator/src/control/tests.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::OsString;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use lazaret_core::identity::{
|
||||
ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, SandboxId,
|
||||
};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn generation() -> GenerationIdentity {
|
||||
GenerationIdentity {
|
||||
agent_profile: "trusted".into(),
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
generation_id: GenerationId::new(7),
|
||||
configuration_fingerprint: ConfigurationFingerprint::new([2; 32]),
|
||||
coordinator_id: CoordinatorId::new([3; 32]),
|
||||
sandbox_id: SandboxId::new([4; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
fn client_config_with_certificate(
|
||||
anchor: &TrustAnchor,
|
||||
certificate: CertificateDer<'static>,
|
||||
private_key: PrivatePkcs8KeyDer<'static>,
|
||||
) -> ClientConfig {
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(anchor.tls_ca_der.clone()).unwrap();
|
||||
let mut config = ClientConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.with_root_certificates(roots)
|
||||
.with_client_auth_cert(vec![certificate], PrivateKeyDer::Pkcs8(private_key))
|
||||
.unwrap();
|
||||
config.alpn_protocols = vec![CONTROL_ALPN.to_vec()];
|
||||
config.resumption = rustls::client::Resumption::disabled();
|
||||
config
|
||||
}
|
||||
|
||||
fn handshake_pair(
|
||||
server_config: ServerConfig,
|
||||
client_config: ClientConfig,
|
||||
) -> (Result<(), rustls::Error>, Result<(), rustls::Error>) {
|
||||
let (mut server_socket, mut client_socket) =
|
||||
std::os::unix::net::UnixStream::pair().unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
let mut connection = ServerConnection::new(Arc::new(server_config)).unwrap();
|
||||
while connection.is_handshaking() {
|
||||
connection
|
||||
.complete_io(&mut server_socket)
|
||||
.map_err(|error| rustls::Error::General(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let mut connection = ClientConnection::new(
|
||||
Arc::new(client_config),
|
||||
ServerName::try_from(CONTROL_SERVER_NAME).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let client = (|| {
|
||||
while connection.is_handshaking() {
|
||||
connection
|
||||
.complete_io(&mut client_socket)
|
||||
.map_err(|error| rustls::Error::General(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
(server.join().unwrap(), client)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_peer_uid_is_required() {
|
||||
assert!(require_peer_uid(1_000, 1_000).is_ok());
|
||||
assert!(matches!(
|
||||
require_peer_uid(1_001, 1_000),
|
||||
Err(ControlError::UnauthorizedPeer)
|
||||
));
|
||||
}
|
||||
|
||||
struct RecordingRunner {
|
||||
observed: Mutex<Option<(OsString, Vec<OsString>)>>,
|
||||
exit: ProcessExit,
|
||||
}
|
||||
|
||||
impl NonInteractiveCommandRunner for RecordingRunner {
|
||||
fn run(
|
||||
&self,
|
||||
target: &TargetCommand,
|
||||
_session_id: SessionId,
|
||||
_emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
|
||||
) -> Result<ProcessExit, ControlError> {
|
||||
*self.observed.lock().unwrap() =
|
||||
Some((target.program().to_owned(), target.arguments().to_vec()));
|
||||
Ok(self.exit)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runsc_exec_uses_the_command_as_the_first_post_container_argument() {
|
||||
let runner = Arc::new(RecordingRunner {
|
||||
observed: Mutex::new(None),
|
||||
exit: ProcessExit {
|
||||
exit_code: Some(23),
|
||||
terminating_signal: None,
|
||||
},
|
||||
});
|
||||
let executor = RunscNonInteractiveExecutor {
|
||||
program: "runsc".into(),
|
||||
global_arguments: vec!["--root=/runtime".into()],
|
||||
container: "container-id".into(),
|
||||
runner: runner.clone(),
|
||||
};
|
||||
let session_id = SessionId::new([9; 32]);
|
||||
let exit = executor
|
||||
.execute(
|
||||
&NonInteractiveSessionRequest {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_id,
|
||||
argv: vec![b"printf".to_vec(), b"hello".to_vec()],
|
||||
},
|
||||
&mut |_| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(exit.exit_code(), Some(23));
|
||||
assert_eq!(
|
||||
runner.observed.lock().unwrap().as_ref().unwrap(),
|
||||
&(
|
||||
OsString::from("runsc"),
|
||||
vec![
|
||||
OsString::from("--root=/runtime"),
|
||||
OsString::from("exec"),
|
||||
OsString::from("-env"),
|
||||
OsString::from(format!(
|
||||
"LAZARET_SESSION_ID={}",
|
||||
hex_lower(session_id.as_bytes())
|
||||
)),
|
||||
OsString::from("container-id"),
|
||||
OsString::from("printf"),
|
||||
OsString::from("hello"),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustls_refuses_client_leaf_with_server_role() {
|
||||
let anchor = TrustAnchor::create(generation()).unwrap();
|
||||
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
|
||||
let certificate = leaf_parameters(
|
||||
"wrong-client-role.invalid",
|
||||
ExtendedKeyUsagePurpose::ServerAuth,
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.unwrap()
|
||||
.signed_by(&key, &anchor.tls_ca)
|
||||
.unwrap();
|
||||
let client_config = client_config_with_certificate(
|
||||
&anchor,
|
||||
certificate.der().clone(),
|
||||
PrivatePkcs8KeyDer::from(key.serialize_der()),
|
||||
);
|
||||
let (server, client) = handshake_pair(anchor.server_config().unwrap(), client_config);
|
||||
assert!(server.is_err() || client.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustls_refuses_server_leaf_with_client_role() {
|
||||
let anchor = TrustAnchor::create(generation()).unwrap();
|
||||
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
|
||||
let certificate = leaf_parameters(
|
||||
CONTROL_SERVER_NAME,
|
||||
ExtendedKeyUsagePurpose::ClientAuth,
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.unwrap()
|
||||
.signed_by(&key, &anchor.tls_ca)
|
||||
.unwrap();
|
||||
let server_config =
|
||||
ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![certificate.der().clone()],
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())),
|
||||
)
|
||||
.unwrap();
|
||||
let client_config =
|
||||
PublishedControlFace::client_config(&anchor.publication_identity(), None).unwrap();
|
||||
let (server, client) = handshake_pair(server_config, client_config);
|
||||
assert!(server.is_err() || client.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -986,476 +986,5 @@ impl EscalationCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
struct SessionAuthorityFiles {
|
||||
binding_name: OsString,
|
||||
binding_identity: MailboxEntryIdentity,
|
||||
registration_name: OsString,
|
||||
registration_identity: MailboxEntryIdentity,
|
||||
}
|
||||
|
||||
struct SessionEscalationAuthority {
|
||||
session: VerifiedSession,
|
||||
files: SessionAuthorityFiles,
|
||||
}
|
||||
|
||||
struct RegistrationCommand {
|
||||
binding: SessionBinding,
|
||||
registration: SessionRegistrationResponse,
|
||||
completed: mpsc::SyncSender<Result<(), String>>,
|
||||
}
|
||||
|
||||
enum EscalationServiceCommand {
|
||||
Register(Box<RegistrationCommand>),
|
||||
Disconnect {
|
||||
session_id: SessionId,
|
||||
completed: mpsc::SyncSender<Result<(), String>>,
|
||||
},
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EscalationServiceHandle {
|
||||
commands: mpsc::Sender<EscalationServiceCommand>,
|
||||
}
|
||||
|
||||
impl EscalationServiceHandle {
|
||||
pub(crate) fn register(
|
||||
&self,
|
||||
binding: SessionBinding,
|
||||
registration: SessionRegistrationResponse,
|
||||
) -> Result<(), EscalationError> {
|
||||
let (completed, result) = mpsc::sync_channel(1);
|
||||
self.commands
|
||||
.send(EscalationServiceCommand::Register(Box::new(
|
||||
RegistrationCommand {
|
||||
binding,
|
||||
registration,
|
||||
completed,
|
||||
},
|
||||
)))
|
||||
.map_err(|_| io::Error::other("escalation service stopped"))?;
|
||||
result
|
||||
.recv()
|
||||
.map_err(|_| io::Error::other("escalation registration was abandoned"))?
|
||||
.map_err(|error| io::Error::other(error).into())
|
||||
}
|
||||
|
||||
pub(crate) fn disconnect(&self, session_id: SessionId) -> Result<(), EscalationError> {
|
||||
let (completed, result) = mpsc::sync_channel(1);
|
||||
self.commands
|
||||
.send(EscalationServiceCommand::Disconnect {
|
||||
session_id,
|
||||
completed,
|
||||
})
|
||||
.map_err(|_| io::Error::other("escalation service stopped"))?;
|
||||
result
|
||||
.recv()
|
||||
.map_err(|_| io::Error::other("escalation disconnect was abandoned"))?
|
||||
.map_err(|error| io::Error::other(error).into())
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&self) {
|
||||
let _ = self.commands.send(EscalationServiceCommand::Stop);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct EscalationService<'a> {
|
||||
configuration: &'a ResolvedDomainConfiguration,
|
||||
publication: ProtocolPublicationIdentity,
|
||||
credentials: SandboxCredentialBundle,
|
||||
credential_files: RegularFileMailbox,
|
||||
inbox: RegularFileMailbox,
|
||||
prompt_transport: Arc<SessionPromptTransport>,
|
||||
coordinator: EscalationCoordinator,
|
||||
ownership: LocalExecutionOwnership,
|
||||
sessions: BTreeMap<SessionId, SessionEscalationAuthority>,
|
||||
active: BTreeMap<RequestId, SessionId>,
|
||||
commands: mpsc::Receiver<EscalationServiceCommand>,
|
||||
}
|
||||
|
||||
pub(crate) struct EscalationServiceInputs<'a> {
|
||||
pub configuration: &'a ResolvedDomainConfiguration,
|
||||
pub publication: ProtocolPublicationIdentity,
|
||||
pub credentials: SandboxCredentialBundle,
|
||||
pub credential_files: RegularFileMailbox,
|
||||
pub inbox: RegularFileMailbox,
|
||||
pub outbox: RegularFileMailbox,
|
||||
pub prompt_transport: Arc<SessionPromptTransport>,
|
||||
pub coordinator_key: SigningKey,
|
||||
pub ownership: ProcessOwnershipContext,
|
||||
pub invoking_uid: u32,
|
||||
}
|
||||
|
||||
impl<'a> EscalationService<'a> {
|
||||
pub(crate) fn new(
|
||||
inputs: EscalationServiceInputs<'a>,
|
||||
) -> Result<(Self, EscalationServiceHandle), EscalationError> {
|
||||
inputs.credential_files.publish(
|
||||
OsStr::new("publication.postcard"),
|
||||
&lazaret_core::codec::encode(&inputs.publication)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
)?;
|
||||
inputs.credential_files.publish(
|
||||
OsStr::new("sandbox-credentials.postcard"),
|
||||
&lazaret_core::codec::encode(&inputs.credentials)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
)?;
|
||||
let (commands, receiver) = mpsc::channel();
|
||||
Ok((
|
||||
Self {
|
||||
configuration: inputs.configuration,
|
||||
publication: inputs.publication,
|
||||
credentials: inputs.credentials,
|
||||
credential_files: inputs.credential_files,
|
||||
inbox: inputs.inbox,
|
||||
prompt_transport: inputs.prompt_transport,
|
||||
coordinator: EscalationCoordinator::new(
|
||||
inputs.coordinator_key,
|
||||
inputs.outbox,
|
||||
inputs.ownership,
|
||||
inputs.invoking_uid,
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(2),
|
||||
),
|
||||
ownership: LocalExecutionOwnership::default(),
|
||||
sessions: BTreeMap::new(),
|
||||
active: BTreeMap::new(),
|
||||
commands: receiver,
|
||||
},
|
||||
EscalationServiceHandle { commands },
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn run(mut self) -> Result<(), EscalationError> {
|
||||
loop {
|
||||
while let Ok(command) = self.commands.try_recv() {
|
||||
if self.apply_command(command)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.process_requests()?;
|
||||
self.process_active()?;
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_command(
|
||||
&mut self,
|
||||
command: EscalationServiceCommand,
|
||||
) -> Result<bool, EscalationError> {
|
||||
match command {
|
||||
EscalationServiceCommand::Register(command) => {
|
||||
let RegistrationCommand {
|
||||
binding,
|
||||
registration,
|
||||
completed,
|
||||
} = *command;
|
||||
let result = self
|
||||
.register(&binding, ®istration)
|
||||
.map_err(|error| error.to_string());
|
||||
let failed = result.is_err();
|
||||
let _ = completed.send(result);
|
||||
if failed {
|
||||
return Err(io::Error::other("session escalation registration failed").into());
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
EscalationServiceCommand::Disconnect {
|
||||
session_id,
|
||||
completed,
|
||||
} => {
|
||||
let result = self
|
||||
.disconnect(session_id)
|
||||
.map_err(|error| error.to_string());
|
||||
let failed = result.is_err();
|
||||
let _ = completed.send(result);
|
||||
if failed {
|
||||
return Err(io::Error::other("session escalation cleanup failed").into());
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
EscalationServiceCommand::Stop => {
|
||||
let sessions = self.sessions.keys().copied().collect::<Vec<_>>();
|
||||
for session_id in sessions {
|
||||
self.disconnect(session_id)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register(
|
||||
&mut self,
|
||||
binding: &SessionBinding,
|
||||
registration: &SessionRegistrationResponse,
|
||||
) -> Result<(), EscalationError> {
|
||||
let session_id = binding.session_id;
|
||||
if self.sessions.contains_key(&session_id) {
|
||||
return Err(EscalationError::DuplicateSessionRequest);
|
||||
}
|
||||
let session = VerifiedSession::new(
|
||||
self.publication.trust_manifest_sha256,
|
||||
self.credentials.trust_manifest.tls_ca_sha256,
|
||||
&self.credentials.trust_manifest,
|
||||
binding.clone(),
|
||||
self.credentials.coordinator_protocol_credential(),
|
||||
self.credentials.sandbox_protocol_credential(),
|
||||
registration.session_capability.clone(),
|
||||
)?;
|
||||
let prefix = mailbox_session_name(session_id);
|
||||
let mut binding_name = prefix.clone();
|
||||
binding_name.push("-binding.postcard");
|
||||
let mut registration_name = prefix;
|
||||
registration_name.push("-registration.postcard");
|
||||
let binding_identity = self.credential_files.publish(
|
||||
&binding_name,
|
||||
&lazaret_core::codec::encode(binding)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
)?;
|
||||
let registration_identity = match self.credential_files.publish(
|
||||
®istration_name,
|
||||
&lazaret_core::codec::encode(registration)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
) {
|
||||
Ok(identity) => identity,
|
||||
Err(error) => {
|
||||
self.credential_files
|
||||
.remove_if_matches(&binding_name, binding_identity)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
self.sessions.insert(
|
||||
session_id,
|
||||
SessionEscalationAuthority {
|
||||
session,
|
||||
files: SessionAuthorityFiles {
|
||||
binding_name,
|
||||
binding_identity,
|
||||
registration_name,
|
||||
registration_identity,
|
||||
},
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disconnect(&mut self, session_id: SessionId) -> Result<(), EscalationError> {
|
||||
self.coordinator.session_lost(session_id)?;
|
||||
self.active.retain(|_, active| *active != session_id);
|
||||
let _ = self.prompt_transport.finish_escalation(session_id);
|
||||
if let Some(authority) = self.sessions.remove(&session_id) {
|
||||
self.credential_files.remove_if_matches(
|
||||
&authority.files.binding_name,
|
||||
authority.files.binding_identity,
|
||||
)?;
|
||||
self.credential_files.remove_if_matches(
|
||||
&authority.files.registration_name,
|
||||
authority.files.registration_identity,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_requests(&mut self) -> Result<(), EscalationError> {
|
||||
for name in self.inbox.names()? {
|
||||
if !is_ready_record(&name) || !name.as_bytes().starts_with(b"request-") {
|
||||
continue;
|
||||
}
|
||||
let Ok((bytes, identity)) = self.inbox.read(&name, MAX_SIGNED_RECORD_BYTES) else {
|
||||
continue;
|
||||
};
|
||||
let matched = self.sessions.values().find_map(|authority| {
|
||||
authority
|
||||
.session
|
||||
.verify_request(&bytes)
|
||||
.ok()
|
||||
.map(|(request, _)| (authority.session.clone(), request))
|
||||
});
|
||||
self.inbox.remove_if_matches(&name, identity)?;
|
||||
let Some((session, request)) = matched else {
|
||||
continue;
|
||||
};
|
||||
let session_id = session.session_binding().session_id;
|
||||
let request_id = request.request_id();
|
||||
let auto = self
|
||||
.configuration
|
||||
.auto_approval_rules()
|
||||
.iter()
|
||||
.find_map(|rule| {
|
||||
rule.authorize(&request)
|
||||
.ok()
|
||||
.map(|executable| (executable, request.command_argv().to_vec()))
|
||||
});
|
||||
let phase = if let Some((executable, command_argv)) = auto {
|
||||
self.coordinator.receive_auto_approved(
|
||||
session,
|
||||
&bytes,
|
||||
executable,
|
||||
command_argv,
|
||||
&mut self.ownership,
|
||||
Instant::now(),
|
||||
)
|
||||
} else {
|
||||
let phase = self.coordinator.receive_for_developer_approval(
|
||||
session.clone(),
|
||||
&bytes,
|
||||
Instant::now(),
|
||||
);
|
||||
let Ok(phase) = phase else {
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = self.prompt_transport.queue_signed_request(session, &bytes) {
|
||||
self.coordinator.session_lost(session_id)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(phase)
|
||||
};
|
||||
let phase = match phase {
|
||||
Ok(phase) => phase,
|
||||
Err(error) if error.is_record_refusal() => continue,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
self.record_phase(request_id, session_id, phase)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_active(&mut self) -> Result<(), EscalationError> {
|
||||
let active = self
|
||||
.active
|
||||
.iter()
|
||||
.map(|(id, session)| (*id, *session))
|
||||
.collect::<Vec<_>>();
|
||||
for (request_id, session_id) in &active {
|
||||
if let Some(decision) = self.prompt_transport.take_decision(*session_id)? {
|
||||
let phase = self.coordinator.apply_developer_decision(
|
||||
*request_id,
|
||||
&decision.signed_decision,
|
||||
&mut DirectDeveloperExecutableResolver::open()?,
|
||||
&mut self.ownership,
|
||||
)?;
|
||||
self.record_phase(*request_id, *session_id, phase)?;
|
||||
}
|
||||
if !self.active.contains_key(request_id) {
|
||||
continue;
|
||||
}
|
||||
let inbox_names = self.inbox.names()?;
|
||||
let prefix = acknowledgment_prefix(*request_id);
|
||||
for name in &inbox_names {
|
||||
if !name.as_bytes().starts_with(&prefix) {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = self.inbox.consume(name, MAX_SIGNED_RECORD_BYTES) else {
|
||||
continue;
|
||||
};
|
||||
match self
|
||||
.coordinator
|
||||
.acknowledge(*request_id, &bytes, Instant::now())
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(error) if error.is_record_refusal() => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let cancellation = cancellation_file_name(*request_id);
|
||||
if inbox_names.contains(&cancellation) {
|
||||
let Ok(bytes) = self.inbox.consume(&cancellation, MAX_SIGNED_RECORD_BYTES) else {
|
||||
continue;
|
||||
};
|
||||
match self.coordinator.cancel(*request_id, &bytes) {
|
||||
Ok(phase) => self.record_phase(*request_id, *session_id, phase)?,
|
||||
Err(error) if error.is_record_refusal() => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.active.contains_key(request_id) {
|
||||
let phase = self.coordinator.progress(*request_id, Instant::now())?;
|
||||
self.record_phase(*request_id, *session_id, phase)?;
|
||||
}
|
||||
}
|
||||
for request_id in self.coordinator.expire(Instant::now())? {
|
||||
if let Some(session_id) = self.active.get(&request_id).copied() {
|
||||
self.record_phase(
|
||||
request_id,
|
||||
session_id,
|
||||
EscalationPhase::Delivered(EscalationOutcome::TimedOut),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_phase(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
session_id: SessionId,
|
||||
phase: EscalationPhase,
|
||||
) -> Result<(), EscalationError> {
|
||||
match phase {
|
||||
EscalationPhase::AwaitingApproval => {
|
||||
self.active.insert(request_id, session_id);
|
||||
}
|
||||
EscalationPhase::Executing => {
|
||||
self.active.insert(request_id, session_id);
|
||||
self.prompt_transport.begin_host_execution(session_id)?;
|
||||
}
|
||||
EscalationPhase::Delivered(_) => {
|
||||
self.active.remove(&request_id);
|
||||
self.prompt_transport.finish_escalation(session_id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn acknowledgment_prefix(request_id: RequestId) -> Vec<u8> {
|
||||
let example = acknowledgment_file_name(request_id, 0);
|
||||
let bytes = example.as_bytes();
|
||||
bytes[..bytes.len() - 26].to_vec()
|
||||
}
|
||||
|
||||
fn validate_executable(executable: impl AsFd, invoking_uid: u32) -> io::Result<()> {
|
||||
let stat = fstat(executable)?;
|
||||
let file_type = FileType::from_raw_mode(stat.st_mode);
|
||||
let mode = Mode::from_raw_mode(stat.st_mode);
|
||||
if !file_type.is_file()
|
||||
|| stat.st_nlink != 1
|
||||
|| (stat.st_uid != 0 && stat.st_uid != invoking_uid)
|
||||
|| mode.intersects(Mode::WGRP | Mode::WOTH)
|
||||
|| !mode.contains(Mode::XUSR)
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"approved executable changed type, links, ownership, mode, or executability",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LocalExecutionOwnership {
|
||||
staged: Vec<StagedProcessOwnership>,
|
||||
}
|
||||
|
||||
impl ExecutionOwnershipRecorder for LocalExecutionOwnership {
|
||||
fn stage(&mut self, ownership: StagedProcessOwnership) -> io::Result<()> {
|
||||
if self.staged.contains(&ownership) {
|
||||
Err(io::Error::other("execution ownership was already staged"))
|
||||
} else {
|
||||
self.staged.push(ownership);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn acknowledge(
|
||||
&mut self,
|
||||
ownership: StagedProcessOwnership,
|
||||
) -> io::Result<ProcessOwnershipCommitAck> {
|
||||
if let Some(index) = self.staged.iter().position(|staged| *staged == ownership) {
|
||||
self.staged.swap_remove(index);
|
||||
Ok(ProcessOwnershipCommitAck::v1(ownership))
|
||||
} else {
|
||||
Err(io::Error::other("execution ownership is not staged"))
|
||||
}
|
||||
}
|
||||
}
|
||||
include!("escalation/service.rs");
|
||||
include!("escalation/local_ownership.rs");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
fn acknowledgment_prefix(request_id: RequestId) -> Vec<u8> {
|
||||
let example = acknowledgment_file_name(request_id, 0);
|
||||
let bytes = example.as_bytes();
|
||||
bytes[..bytes.len() - 26].to_vec()
|
||||
}
|
||||
|
||||
fn validate_executable(executable: impl AsFd, invoking_uid: u32) -> io::Result<()> {
|
||||
let stat = fstat(executable)?;
|
||||
let file_type = FileType::from_raw_mode(stat.st_mode);
|
||||
let mode = Mode::from_raw_mode(stat.st_mode);
|
||||
if !file_type.is_file()
|
||||
|| stat.st_nlink != 1
|
||||
|| (stat.st_uid != 0 && stat.st_uid != invoking_uid)
|
||||
|| mode.intersects(Mode::WGRP | Mode::WOTH)
|
||||
|| !mode.contains(Mode::XUSR)
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"approved executable changed type, links, ownership, mode, or executability",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LocalExecutionOwnership {
|
||||
staged: Vec<StagedProcessOwnership>,
|
||||
}
|
||||
|
||||
impl ExecutionOwnershipRecorder for LocalExecutionOwnership {
|
||||
fn stage(&mut self, ownership: StagedProcessOwnership) -> io::Result<()> {
|
||||
if self.staged.contains(&ownership) {
|
||||
Err(io::Error::other("execution ownership was already staged"))
|
||||
} else {
|
||||
self.staged.push(ownership);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn acknowledge(
|
||||
&mut self,
|
||||
ownership: StagedProcessOwnership,
|
||||
) -> io::Result<ProcessOwnershipCommitAck> {
|
||||
if let Some(index) = self.staged.iter().position(|staged| *staged == ownership) {
|
||||
self.staged.swap_remove(index);
|
||||
Ok(ProcessOwnershipCommitAck::v1(ownership))
|
||||
} else {
|
||||
Err(io::Error::other("execution ownership is not staged"))
|
||||
}
|
||||
}
|
||||
}
|
||||
423
crates/lazaret-host-coordinator/src/escalation/service.rs
Normal file
423
crates/lazaret-host-coordinator/src/escalation/service.rs
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
struct SessionAuthorityFiles {
|
||||
binding_name: OsString,
|
||||
binding_identity: MailboxEntryIdentity,
|
||||
registration_name: OsString,
|
||||
registration_identity: MailboxEntryIdentity,
|
||||
}
|
||||
|
||||
struct SessionEscalationAuthority {
|
||||
session: VerifiedSession,
|
||||
files: SessionAuthorityFiles,
|
||||
}
|
||||
|
||||
struct RegistrationCommand {
|
||||
binding: SessionBinding,
|
||||
registration: SessionRegistrationResponse,
|
||||
completed: mpsc::SyncSender<Result<(), String>>,
|
||||
}
|
||||
|
||||
enum EscalationServiceCommand {
|
||||
Register(Box<RegistrationCommand>),
|
||||
Disconnect {
|
||||
session_id: SessionId,
|
||||
completed: mpsc::SyncSender<Result<(), String>>,
|
||||
},
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EscalationServiceHandle {
|
||||
commands: mpsc::Sender<EscalationServiceCommand>,
|
||||
}
|
||||
|
||||
impl EscalationServiceHandle {
|
||||
pub(crate) fn register(
|
||||
&self,
|
||||
binding: SessionBinding,
|
||||
registration: SessionRegistrationResponse,
|
||||
) -> Result<(), EscalationError> {
|
||||
let (completed, result) = mpsc::sync_channel(1);
|
||||
self.commands
|
||||
.send(EscalationServiceCommand::Register(Box::new(
|
||||
RegistrationCommand {
|
||||
binding,
|
||||
registration,
|
||||
completed,
|
||||
},
|
||||
)))
|
||||
.map_err(|_| io::Error::other("escalation service stopped"))?;
|
||||
result
|
||||
.recv()
|
||||
.map_err(|_| io::Error::other("escalation registration was abandoned"))?
|
||||
.map_err(|error| io::Error::other(error).into())
|
||||
}
|
||||
|
||||
pub(crate) fn disconnect(&self, session_id: SessionId) -> Result<(), EscalationError> {
|
||||
let (completed, result) = mpsc::sync_channel(1);
|
||||
self.commands
|
||||
.send(EscalationServiceCommand::Disconnect {
|
||||
session_id,
|
||||
completed,
|
||||
})
|
||||
.map_err(|_| io::Error::other("escalation service stopped"))?;
|
||||
result
|
||||
.recv()
|
||||
.map_err(|_| io::Error::other("escalation disconnect was abandoned"))?
|
||||
.map_err(|error| io::Error::other(error).into())
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&self) {
|
||||
let _ = self.commands.send(EscalationServiceCommand::Stop);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct EscalationService<'a> {
|
||||
configuration: &'a ResolvedDomainConfiguration,
|
||||
publication: ProtocolPublicationIdentity,
|
||||
credentials: SandboxCredentialBundle,
|
||||
credential_files: RegularFileMailbox,
|
||||
inbox: RegularFileMailbox,
|
||||
prompt_transport: Arc<SessionPromptTransport>,
|
||||
coordinator: EscalationCoordinator,
|
||||
ownership: LocalExecutionOwnership,
|
||||
sessions: BTreeMap<SessionId, SessionEscalationAuthority>,
|
||||
active: BTreeMap<RequestId, SessionId>,
|
||||
commands: mpsc::Receiver<EscalationServiceCommand>,
|
||||
}
|
||||
|
||||
pub(crate) struct EscalationServiceInputs<'a> {
|
||||
pub configuration: &'a ResolvedDomainConfiguration,
|
||||
pub publication: ProtocolPublicationIdentity,
|
||||
pub credentials: SandboxCredentialBundle,
|
||||
pub credential_files: RegularFileMailbox,
|
||||
pub inbox: RegularFileMailbox,
|
||||
pub outbox: RegularFileMailbox,
|
||||
pub prompt_transport: Arc<SessionPromptTransport>,
|
||||
pub coordinator_key: SigningKey,
|
||||
pub ownership: ProcessOwnershipContext,
|
||||
pub invoking_uid: u32,
|
||||
}
|
||||
|
||||
impl<'a> EscalationService<'a> {
|
||||
pub(crate) fn new(
|
||||
inputs: EscalationServiceInputs<'a>,
|
||||
) -> Result<(Self, EscalationServiceHandle), EscalationError> {
|
||||
inputs.credential_files.publish(
|
||||
OsStr::new("publication.postcard"),
|
||||
&lazaret_core::codec::encode(&inputs.publication)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
)?;
|
||||
inputs.credential_files.publish(
|
||||
OsStr::new("sandbox-credentials.postcard"),
|
||||
&lazaret_core::codec::encode(&inputs.credentials)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
)?;
|
||||
let (commands, receiver) = mpsc::channel();
|
||||
Ok((
|
||||
Self {
|
||||
configuration: inputs.configuration,
|
||||
publication: inputs.publication,
|
||||
credentials: inputs.credentials,
|
||||
credential_files: inputs.credential_files,
|
||||
inbox: inputs.inbox,
|
||||
prompt_transport: inputs.prompt_transport,
|
||||
coordinator: EscalationCoordinator::new(
|
||||
inputs.coordinator_key,
|
||||
inputs.outbox,
|
||||
inputs.ownership,
|
||||
inputs.invoking_uid,
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(2),
|
||||
),
|
||||
ownership: LocalExecutionOwnership::default(),
|
||||
sessions: BTreeMap::new(),
|
||||
active: BTreeMap::new(),
|
||||
commands: receiver,
|
||||
},
|
||||
EscalationServiceHandle { commands },
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn run(mut self) -> Result<(), EscalationError> {
|
||||
loop {
|
||||
while let Ok(command) = self.commands.try_recv() {
|
||||
if self.apply_command(command)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.process_requests()?;
|
||||
self.process_active()?;
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_command(
|
||||
&mut self,
|
||||
command: EscalationServiceCommand,
|
||||
) -> Result<bool, EscalationError> {
|
||||
match command {
|
||||
EscalationServiceCommand::Register(command) => {
|
||||
let RegistrationCommand {
|
||||
binding,
|
||||
registration,
|
||||
completed,
|
||||
} = *command;
|
||||
let result = self
|
||||
.register(&binding, ®istration)
|
||||
.map_err(|error| error.to_string());
|
||||
let failed = result.is_err();
|
||||
let _ = completed.send(result);
|
||||
if failed {
|
||||
return Err(io::Error::other("session escalation registration failed").into());
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
EscalationServiceCommand::Disconnect {
|
||||
session_id,
|
||||
completed,
|
||||
} => {
|
||||
let result = self
|
||||
.disconnect(session_id)
|
||||
.map_err(|error| error.to_string());
|
||||
let failed = result.is_err();
|
||||
let _ = completed.send(result);
|
||||
if failed {
|
||||
return Err(io::Error::other("session escalation cleanup failed").into());
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
EscalationServiceCommand::Stop => {
|
||||
let sessions = self.sessions.keys().copied().collect::<Vec<_>>();
|
||||
for session_id in sessions {
|
||||
self.disconnect(session_id)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register(
|
||||
&mut self,
|
||||
binding: &SessionBinding,
|
||||
registration: &SessionRegistrationResponse,
|
||||
) -> Result<(), EscalationError> {
|
||||
let session_id = binding.session_id;
|
||||
if self.sessions.contains_key(&session_id) {
|
||||
return Err(EscalationError::DuplicateSessionRequest);
|
||||
}
|
||||
let session = VerifiedSession::new(
|
||||
self.publication.trust_manifest_sha256,
|
||||
self.credentials.trust_manifest.tls_ca_sha256,
|
||||
&self.credentials.trust_manifest,
|
||||
binding.clone(),
|
||||
self.credentials.coordinator_protocol_credential(),
|
||||
self.credentials.sandbox_protocol_credential(),
|
||||
registration.session_capability.clone(),
|
||||
)?;
|
||||
let prefix = mailbox_session_name(session_id);
|
||||
let mut binding_name = prefix.clone();
|
||||
binding_name.push("-binding.postcard");
|
||||
let mut registration_name = prefix;
|
||||
registration_name.push("-registration.postcard");
|
||||
let binding_identity = self.credential_files.publish(
|
||||
&binding_name,
|
||||
&lazaret_core::codec::encode(binding)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
)?;
|
||||
let registration_identity = match self.credential_files.publish(
|
||||
®istration_name,
|
||||
&lazaret_core::codec::encode(registration)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
) {
|
||||
Ok(identity) => identity,
|
||||
Err(error) => {
|
||||
self.credential_files
|
||||
.remove_if_matches(&binding_name, binding_identity)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
self.sessions.insert(
|
||||
session_id,
|
||||
SessionEscalationAuthority {
|
||||
session,
|
||||
files: SessionAuthorityFiles {
|
||||
binding_name,
|
||||
binding_identity,
|
||||
registration_name,
|
||||
registration_identity,
|
||||
},
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disconnect(&mut self, session_id: SessionId) -> Result<(), EscalationError> {
|
||||
self.coordinator.session_lost(session_id)?;
|
||||
self.active.retain(|_, active| *active != session_id);
|
||||
let _ = self.prompt_transport.finish_escalation(session_id);
|
||||
if let Some(authority) = self.sessions.remove(&session_id) {
|
||||
self.credential_files.remove_if_matches(
|
||||
&authority.files.binding_name,
|
||||
authority.files.binding_identity,
|
||||
)?;
|
||||
self.credential_files.remove_if_matches(
|
||||
&authority.files.registration_name,
|
||||
authority.files.registration_identity,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_requests(&mut self) -> Result<(), EscalationError> {
|
||||
for name in self.inbox.names()? {
|
||||
if !is_ready_record(&name) || !name.as_bytes().starts_with(b"request-") {
|
||||
continue;
|
||||
}
|
||||
let Ok((bytes, identity)) = self.inbox.read(&name, MAX_SIGNED_RECORD_BYTES) else {
|
||||
continue;
|
||||
};
|
||||
let matched = self.sessions.values().find_map(|authority| {
|
||||
authority
|
||||
.session
|
||||
.verify_request(&bytes)
|
||||
.ok()
|
||||
.map(|(request, _)| (authority.session.clone(), request))
|
||||
});
|
||||
self.inbox.remove_if_matches(&name, identity)?;
|
||||
let Some((session, request)) = matched else {
|
||||
continue;
|
||||
};
|
||||
let session_id = session.session_binding().session_id;
|
||||
let request_id = request.request_id();
|
||||
let auto = self
|
||||
.configuration
|
||||
.auto_approval_rules()
|
||||
.iter()
|
||||
.find_map(|rule| {
|
||||
rule.authorize(&request)
|
||||
.ok()
|
||||
.map(|executable| (executable, request.command_argv().to_vec()))
|
||||
});
|
||||
let phase = if let Some((executable, command_argv)) = auto {
|
||||
self.coordinator.receive_auto_approved(
|
||||
session,
|
||||
&bytes,
|
||||
executable,
|
||||
command_argv,
|
||||
&mut self.ownership,
|
||||
Instant::now(),
|
||||
)
|
||||
} else {
|
||||
let phase = self.coordinator.receive_for_developer_approval(
|
||||
session.clone(),
|
||||
&bytes,
|
||||
Instant::now(),
|
||||
);
|
||||
let Ok(phase) = phase else {
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = self.prompt_transport.queue_signed_request(session, &bytes) {
|
||||
self.coordinator.session_lost(session_id)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(phase)
|
||||
};
|
||||
let phase = match phase {
|
||||
Ok(phase) => phase,
|
||||
Err(error) if error.is_record_refusal() => continue,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
self.record_phase(request_id, session_id, phase)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_active(&mut self) -> Result<(), EscalationError> {
|
||||
let active = self
|
||||
.active
|
||||
.iter()
|
||||
.map(|(id, session)| (*id, *session))
|
||||
.collect::<Vec<_>>();
|
||||
for (request_id, session_id) in &active {
|
||||
if let Some(decision) = self.prompt_transport.take_decision(*session_id)? {
|
||||
let phase = self.coordinator.apply_developer_decision(
|
||||
*request_id,
|
||||
&decision.signed_decision,
|
||||
&mut DirectDeveloperExecutableResolver::open()?,
|
||||
&mut self.ownership,
|
||||
)?;
|
||||
self.record_phase(*request_id, *session_id, phase)?;
|
||||
}
|
||||
if !self.active.contains_key(request_id) {
|
||||
continue;
|
||||
}
|
||||
let inbox_names = self.inbox.names()?;
|
||||
let prefix = acknowledgment_prefix(*request_id);
|
||||
for name in &inbox_names {
|
||||
if !name.as_bytes().starts_with(&prefix) {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = self.inbox.consume(name, MAX_SIGNED_RECORD_BYTES) else {
|
||||
continue;
|
||||
};
|
||||
match self
|
||||
.coordinator
|
||||
.acknowledge(*request_id, &bytes, Instant::now())
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(error) if error.is_record_refusal() => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let cancellation = cancellation_file_name(*request_id);
|
||||
if inbox_names.contains(&cancellation) {
|
||||
let Ok(bytes) = self.inbox.consume(&cancellation, MAX_SIGNED_RECORD_BYTES) else {
|
||||
continue;
|
||||
};
|
||||
match self.coordinator.cancel(*request_id, &bytes) {
|
||||
Ok(phase) => self.record_phase(*request_id, *session_id, phase)?,
|
||||
Err(error) if error.is_record_refusal() => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.active.contains_key(request_id) {
|
||||
let phase = self.coordinator.progress(*request_id, Instant::now())?;
|
||||
self.record_phase(*request_id, *session_id, phase)?;
|
||||
}
|
||||
}
|
||||
for request_id in self.coordinator.expire(Instant::now())? {
|
||||
if let Some(session_id) = self.active.get(&request_id).copied() {
|
||||
self.record_phase(
|
||||
request_id,
|
||||
session_id,
|
||||
EscalationPhase::Delivered(EscalationOutcome::TimedOut),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_phase(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
session_id: SessionId,
|
||||
phase: EscalationPhase,
|
||||
) -> Result<(), EscalationError> {
|
||||
match phase {
|
||||
EscalationPhase::AwaitingApproval => {
|
||||
self.active.insert(request_id, session_id);
|
||||
}
|
||||
EscalationPhase::Executing => {
|
||||
self.active.insert(request_id, session_id);
|
||||
self.prompt_transport.begin_host_execution(session_id)?;
|
||||
}
|
||||
EscalationPhase::Delivered(_) => {
|
||||
self.active.remove(&request_id);
|
||||
self.prompt_transport.finish_escalation(session_id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue