Fix rootless launches with subordinate ID maps
Resolve and verify canonical caller subordinate ranges, launch preparation and runtime workers through mapped causal acquisition, and keep runsc in the existing user namespace. Repair descriptor-relative OCI cleanup and absent-mount recovery so failed launches can progress to the corrected path.
This commit is contained in:
parent
b9dbd78087
commit
73e3e9fdee
5 changed files with 1424 additions and 172 deletions
|
|
@ -7,3 +7,4 @@ pub mod ipc;
|
|||
pub mod platform;
|
||||
pub mod private_role;
|
||||
pub mod process;
|
||||
pub mod user_namespace;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub const PID_FS_MAGIC: i64 = 0x5049_4446;
|
|||
const BARRIER_RELEASE: &[u8; 32] = b"LAZARET-CAUSAL-BARRIER-RELEASE!!";
|
||||
pub const PRIVATE_PROCESS_ROLE: &str = "--lazaret-private-process-role";
|
||||
const WRAPPER_ROLE: &str = "launch-wrapper";
|
||||
const MAPPED_WRAPPER_ROLE: &str = "mapped-launch-wrapper";
|
||||
const SIGCHLD_PROBE_ROLE: &str = "sigchld-probe";
|
||||
static CHILD_BROKER: OnceLock<ChildBroker> = OnceLock::new();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,18 @@ use super::recovery::{
|
|||
read_start_time, require_default_sigchld, require_host_pid_namespace, require_reapable_sigchld,
|
||||
};
|
||||
use super::{
|
||||
AcknowledgedChildAcquisition, AsFd, BarrierRelease, BootstrapChild, CapturedProcess,
|
||||
ChildBroker, ChildStandardIo, ChildStandardIoPolicy, File, HostPidLocator, OFlags, OsString,
|
||||
OwnedFd, PRIVATE_PROCESS_ROLE, ParentEdge, Pid, PidfdFlags, PidfsProcessIdentityV1, PollFd,
|
||||
PollFlags, ProcessExit, ProcessOwnershipCommitAck, ProcessOwnershipContext,
|
||||
ProcessStartTimeTicks, RetainedProcess, SIGCHLD_PROBE_ROLE, Signal, StagedProcessOwnership,
|
||||
Stdio, TargetCommand, Timespec, WRAPPER_ROLE, WaitId, WaitIdOptions, fcntl_getfl, fcntl_setfl,
|
||||
io, launch_barrier, poll, recover_process, require_captured_parent_edge,
|
||||
require_parent_edge_standard_io,
|
||||
AcknowledgedChildAcquisition, AsFd, BARRIER_RELEASE, BarrierRelease, BootstrapChild,
|
||||
CapturedProcess, ChildBroker, ChildStandardIo, ChildStandardIoPolicy, File, HostPidLocator,
|
||||
MAPPED_WRAPPER_ROLE, OFlags, OsString, OwnedFd, PRIVATE_PROCESS_ROLE, ParentEdge, Pid,
|
||||
PidfdFlags, PidfsProcessIdentityV1, PollFd, PollFlags, ProcessExit, ProcessOwnershipCommitAck,
|
||||
ProcessOwnershipContext, ProcessStartTimeTicks, RetainedProcess, SIGCHLD_PROBE_ROLE, Signal,
|
||||
StagedProcessOwnership, Stdio, TargetCommand, Timespec, WRAPPER_ROLE, WaitId, WaitIdOptions,
|
||||
fcntl_getfl, fcntl_setfl, io, launch_barrier, poll, recover_process,
|
||||
require_captured_parent_edge, require_parent_edge_standard_io,
|
||||
};
|
||||
use crate::ipc::private_stream_pair;
|
||||
use crate::user_namespace::UserNamespaceMap;
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
impl ChildBroker {
|
||||
/// Spawns the private Host Coordinator bootstrap with exact pipe ownership and pidfd reaping.
|
||||
|
|
@ -132,6 +135,64 @@ impl ChildBroker {
|
|||
}
|
||||
}
|
||||
|
||||
/// Runs a non-durable direct child as root in the supplied caller-configured user namespace.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses invalid mapping, causal launch, helper execution, map attestation, or child exit.
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
pub fn run_transient_in_user_namespace(
|
||||
&self,
|
||||
target: &TargetCommand,
|
||||
map: &UserNamespaceMap,
|
||||
) -> io::Result<ProcessExit> {
|
||||
let _guard = self
|
||||
.exclusion
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
||||
require_parent_edge_standard_io(target.parent_edge(), ChildStandardIoPolicy::Inherit)?;
|
||||
require_default_sigchld()?;
|
||||
require_reapable_sigchld()?;
|
||||
map.validate()?;
|
||||
let parent = current_process_identity()?;
|
||||
let (handshake, child_handshake) = private_stream_pair()?;
|
||||
let mut command = mapped_wrapper_command();
|
||||
append_parent_identity(&mut command, parent);
|
||||
target.append_to(&mut command);
|
||||
command
|
||||
.stdin(Stdio::from(child_handshake))
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit());
|
||||
let mut child = command.spawn()?;
|
||||
let process = match acquire_spawned_child(child.id()) {
|
||||
Ok(process) => process,
|
||||
Err(error) => {
|
||||
drop(handshake);
|
||||
let _ = child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = configure_user_namespace(&process, File::from(handshake), map) {
|
||||
return fail_running_mapped(process, child, error);
|
||||
}
|
||||
let status =
|
||||
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?
|
||||
.ok_or_else(|| io::Error::other("mapped transient child exited without status"))?;
|
||||
let exit = ProcessExit {
|
||||
exit_code: status.exit_status(),
|
||||
terminating_signal: status.terminating_signal(),
|
||||
};
|
||||
drop(child);
|
||||
drop(process);
|
||||
if exit.exit_code == Some(0) && exit.terminating_signal.is_none() {
|
||||
Ok(exit)
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"mapped transient child failed with exit {:?} and signal {:?}",
|
||||
exit.exit_code, exit.terminating_signal
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a non-durable direct child with split captured output and exact retained authority.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -223,6 +284,89 @@ impl ChildBroker {
|
|||
)
|
||||
}
|
||||
|
||||
/// Acquires an exact child as root in a helper-configured subordinate user namespace.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses staging, acknowledgment, namespace helper, live-map attestation, or release
|
||||
/// failures and reaps the child on every failure.
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
pub fn acquire_with_stdio_in_user_namespace<Stage, Acknowledge>(
|
||||
&self,
|
||||
acquisition: AcknowledgedChildAcquisition<'_>,
|
||||
map: &UserNamespaceMap,
|
||||
stage: Stage,
|
||||
acknowledge: Acknowledge,
|
||||
) -> io::Result<RetainedProcess>
|
||||
where
|
||||
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
|
||||
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
|
||||
{
|
||||
let _guard = self
|
||||
.exclusion
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
||||
require_parent_edge_standard_io(
|
||||
acquisition.target.parent_edge(),
|
||||
acquisition.standard_io.policy(),
|
||||
)?;
|
||||
require_default_sigchld()?;
|
||||
require_reapable_sigchld()?;
|
||||
map.validate()?;
|
||||
let (handshake, child_handshake) = private_stream_pair()?;
|
||||
let mut command = mapped_wrapper_command();
|
||||
append_parent_identity(&mut command, acquisition.expected_parent);
|
||||
acquisition.target.append_to(&mut command);
|
||||
command.stdin(Stdio::from(child_handshake));
|
||||
let mut standard_io = acquisition.standard_io;
|
||||
if let Some(stdout) = standard_io.stdout.take() {
|
||||
command.stdout(stdout);
|
||||
}
|
||||
if let Some(stderr) = standard_io.stderr.take() {
|
||||
command.stderr(stderr);
|
||||
}
|
||||
let mut child = command.spawn()?;
|
||||
let process = match acquire_spawned_child(child.id()) {
|
||||
Ok(process) => process,
|
||||
Err(error) => {
|
||||
drop(handshake);
|
||||
let _ = child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let staged = StagedProcessOwnership::v1(
|
||||
acquisition.ownership.domain_id,
|
||||
acquisition.ownership.generation_id,
|
||||
acquisition.ownership.coordinator_id,
|
||||
acquisition.ownership.slot,
|
||||
process.identity,
|
||||
);
|
||||
if let Err(error) = stage(staged) {
|
||||
drop(handshake);
|
||||
return fail_after_pidfd_closed(process, error);
|
||||
}
|
||||
let acknowledgment = match acknowledge(staged) {
|
||||
Ok(acknowledgment) => acknowledgment,
|
||||
Err(error) => {
|
||||
drop(handshake);
|
||||
return fail_after_pidfd_closed(process, error);
|
||||
}
|
||||
};
|
||||
if !acknowledgment.acknowledges(&staged) {
|
||||
drop(handshake);
|
||||
return fail_after_pidfd_closed(
|
||||
process,
|
||||
io::Error::other(
|
||||
"process ownership acknowledgment does not match staged ownership",
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Err(error) = configure_user_namespace(&process, File::from(handshake), map) {
|
||||
return fail_running_mapped(process, child, error);
|
||||
}
|
||||
drop(child);
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
/// Acquires an exact child while retaining its blocked wrapper across one acknowledged
|
||||
/// parent transition.
|
||||
///
|
||||
|
|
@ -439,6 +583,84 @@ fn child_pipe_file(fd: OwnedFd) -> io::Result<File> {
|
|||
Ok(File::from(fd))
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_types)]
|
||||
fn mapped_wrapper_command() -> std::process::Command {
|
||||
private_role_command(MAPPED_WRAPPER_ROLE)
|
||||
}
|
||||
|
||||
fn configure_user_namespace(
|
||||
process: &RetainedProcess,
|
||||
mut handshake: File,
|
||||
map: &UserNamespaceMap,
|
||||
) -> io::Result<()> {
|
||||
handshake.write_all(BARRIER_RELEASE)?;
|
||||
handshake.flush()?;
|
||||
let mut marker = [0_u8; 1];
|
||||
handshake.read_exact(&mut marker)?;
|
||||
if marker != *b"u" {
|
||||
return Err(io::Error::other(
|
||||
"mapped child sent an invalid user-namespace marker",
|
||||
));
|
||||
}
|
||||
let pid = process.identity.host_pid().get();
|
||||
run_mapping_helper("/usr/bin/newuidmap", &map.uid_helper_arguments(pid))?;
|
||||
run_mapping_helper("/usr/bin/newgidmap", &map.gid_helper_arguments(pid))?;
|
||||
map.verify_process(pid)?;
|
||||
handshake.write_all(b"m")?;
|
||||
handshake.flush()
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn run_mapping_helper(program: &str, arguments: &[String]) -> io::Result<()> {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command
|
||||
.args(arguments)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command.spawn()?;
|
||||
let process = match acquire_spawned_child(child.id()) {
|
||||
Ok(process) => process,
|
||||
Err(error) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let status =
|
||||
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?
|
||||
.ok_or_else(|| io::Error::other("ID-map helper exited without status"))?;
|
||||
let mut detail = String::new();
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
stderr.take(4096).read_to_string(&mut detail)?;
|
||||
}
|
||||
drop(child);
|
||||
drop(process);
|
||||
if status.exit_status() == Some(0) && status.terminating_signal().is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"{program} failed with exit {:?} and signal {:?}: {}",
|
||||
status.exit_status(),
|
||||
status.terminating_signal(),
|
||||
detail.trim()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn fail_running_mapped<T>(
|
||||
process: RetainedProcess,
|
||||
child: std::process::Child,
|
||||
error: io::Error,
|
||||
) -> io::Result<T> {
|
||||
let _ = process.send_signal(Signal::KILL);
|
||||
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
|
||||
drop(child);
|
||||
drop(process);
|
||||
Err(error)
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn fail_running_captured<T>(
|
||||
process: RetainedProcess,
|
||||
|
|
@ -476,12 +698,57 @@ pub fn dispatch_private_process_role(arguments: &[OsString]) -> Option<io::Resul
|
|||
Some(BarrierWait::from_stdin().and_then(BarrierWait::wait))
|
||||
}
|
||||
Some(WRAPPER_ROLE) => Some(run_wrapper_role(arguments[2..].to_vec())),
|
||||
Some(MAPPED_WRAPPER_ROLE) => Some(run_mapped_wrapper_role(arguments[2..].to_vec())),
|
||||
_ => Some(Err(io::Error::other(
|
||||
"unknown or malformed private process role",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn run_mapped_wrapper_role(arguments: Vec<OsString>) -> io::Result<()> {
|
||||
let (expected_parent, target) = parse_wrapper_arguments(arguments)?;
|
||||
let mut release = [0_u8; BARRIER_RELEASE.len()];
|
||||
std::io::stdin().read_exact(&mut release)?;
|
||||
if release != *BARRIER_RELEASE {
|
||||
return Err(io::Error::other(
|
||||
"mapped wrapper launch release is malformed",
|
||||
));
|
||||
}
|
||||
match target.parent_edge {
|
||||
ParentEdge::ParentDeathKill => {
|
||||
arm_parent_death_and_recheck(Signal::KILL, expected_parent)?;
|
||||
}
|
||||
ParentEdge::LifetimePipe => {
|
||||
require_lifetime_pipe_and_recheck(expected_parent)?;
|
||||
}
|
||||
}
|
||||
rustix::thread::unshare(rustix::thread::UnshareFlags::NEWUSER)?;
|
||||
let stdin = std::io::stdin();
|
||||
rustix::io::write(&stdin, b"u")?;
|
||||
let mut marker = [0_u8; 1];
|
||||
if rustix::io::read(&stdin, &mut marker)? != 1 || marker != *b"m" {
|
||||
return Err(io::Error::other(
|
||||
"mapped wrapper did not receive a valid map release",
|
||||
));
|
||||
}
|
||||
rustix::io::fcntl_setfd(&stdin, rustix::io::FdFlags::CLOEXEC)?;
|
||||
rustix::thread::set_thread_res_gid(
|
||||
rustix::process::Gid::ROOT,
|
||||
rustix::process::Gid::ROOT,
|
||||
rustix::process::Gid::ROOT,
|
||||
)?;
|
||||
rustix::thread::set_thread_res_uid(
|
||||
rustix::process::Uid::ROOT,
|
||||
rustix::process::Uid::ROOT,
|
||||
rustix::process::Uid::ROOT,
|
||||
)?;
|
||||
if let Some(stdin) = target.stdin.as_ref() {
|
||||
rustix::stdio::dup2_stdin(stdin)?;
|
||||
}
|
||||
Err(exec_target(target))
|
||||
}
|
||||
|
||||
fn run_wrapper_role(arguments: Vec<OsString>) -> io::Result<()> {
|
||||
let (expected_parent, target) = parse_wrapper_arguments(arguments)?;
|
||||
BarrierWait::from_stdin()?.wait()?;
|
||||
|
|
|
|||
315
crates/lazaret-linux/src/user_namespace.rs
Normal file
315
crates/lazaret-linux/src/user_namespace.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write as _;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use rustix::process::{getgid, getuid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const REQUIRED_SUBORDINATE_IDS: u32 = 65_536;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct UserNamespaceIdRange {
|
||||
pub container_id: u32,
|
||||
pub parent_id: u32,
|
||||
pub size: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct UserNamespaceMap {
|
||||
pub uid: Vec<UserNamespaceIdRange>,
|
||||
pub gid: Vec<UserNamespaceIdRange>,
|
||||
}
|
||||
|
||||
impl UserNamespaceMap {
|
||||
/// Resolves the invoking identity and the lowest sufficient configured subordinate ranges.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses missing, malformed, overlapping, or insufficient `/etc/subuid` and `/etc/subgid`
|
||||
/// allocations.
|
||||
pub fn resolve_current() -> io::Result<Self> {
|
||||
let uid = getuid().as_raw();
|
||||
let gid = getgid().as_raw();
|
||||
let names = account_names(uid)?;
|
||||
let subordinate_user_start = select_subordinate_range(Path::new("/etc/subuid"), &names)?;
|
||||
let subordinate_group_start = select_subordinate_range(Path::new("/etc/subgid"), &names)?;
|
||||
let map = Self {
|
||||
uid: vec![
|
||||
UserNamespaceIdRange {
|
||||
container_id: 0,
|
||||
parent_id: uid,
|
||||
size: 1,
|
||||
},
|
||||
UserNamespaceIdRange {
|
||||
container_id: 1,
|
||||
parent_id: subordinate_user_start,
|
||||
size: REQUIRED_SUBORDINATE_IDS,
|
||||
},
|
||||
],
|
||||
gid: vec![
|
||||
UserNamespaceIdRange {
|
||||
container_id: 0,
|
||||
parent_id: gid,
|
||||
size: 1,
|
||||
},
|
||||
UserNamespaceIdRange {
|
||||
container_id: 1,
|
||||
parent_id: subordinate_group_start,
|
||||
size: REQUIRED_SUBORDINATE_IDS,
|
||||
},
|
||||
],
|
||||
};
|
||||
map.validate()?;
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses empty, zero-sized, overflowing, overlapping, or rootless-incompatible maps.
|
||||
pub fn validate(&self) -> io::Result<()> {
|
||||
validate_ranges("UID", &self.uid, getuid().as_raw())?;
|
||||
validate_ranges("GID", &self.gid, getgid().as_raw())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn uid_helper_arguments(&self, pid: u32) -> Vec<String> {
|
||||
helper_arguments(pid, &self.uid)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn gid_helper_arguments(&self, pid: u32) -> Vec<String> {
|
||||
helper_arguments(pid, &self.gid)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn uid_proc_text(&self) -> String {
|
||||
proc_text(&self.uid)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn gid_proc_text(&self) -> String {
|
||||
proc_text(&self.gid)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses an unreadable process or a live UID/GID map that differs from this map.
|
||||
pub fn verify_process(&self, pid: u32) -> io::Result<()> {
|
||||
verify_proc_map(&format!("/proc/{pid}/uid_map"), &self.uid, "UID")?;
|
||||
verify_proc_map(&format!("/proc/{pid}/gid_map"), &self.gid, "GID")
|
||||
}
|
||||
}
|
||||
|
||||
fn account_names(uid: u32) -> io::Result<BTreeSet<String>> {
|
||||
let mut names = BTreeSet::from([uid.to_string()]);
|
||||
for line in std::fs::read_to_string("/etc/passwd")?.lines() {
|
||||
let fields = line.split(':').collect::<Vec<_>>();
|
||||
if fields.len() >= 3 && fields[2].parse::<u32>().ok() == Some(uid) {
|
||||
names.insert(fields[0].to_owned());
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn select_subordinate_range(path: &Path, names: &BTreeSet<String>) -> io::Result<u32> {
|
||||
select_subordinate_range_contents(
|
||||
&path.display().to_string(),
|
||||
&std::fs::read_to_string(path)?,
|
||||
names,
|
||||
)
|
||||
}
|
||||
|
||||
fn select_subordinate_range_contents(
|
||||
label: &str,
|
||||
contents: &str,
|
||||
names: &BTreeSet<String>,
|
||||
) -> io::Result<u32> {
|
||||
let mut candidates = Vec::new();
|
||||
for (line_number, line) in contents.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fields = line.split(':').collect::<Vec<_>>();
|
||||
if fields.len() != 3 {
|
||||
return Err(io::Error::other(format!(
|
||||
"{} row {} is malformed",
|
||||
label,
|
||||
line_number + 1
|
||||
)));
|
||||
}
|
||||
if !names.contains(fields[0]) {
|
||||
continue;
|
||||
}
|
||||
let start = fields[1].parse::<u32>().map_err(|_| {
|
||||
io::Error::other(format!(
|
||||
"{} row {} has an invalid start",
|
||||
label,
|
||||
line_number + 1
|
||||
))
|
||||
})?;
|
||||
let count = fields[2].parse::<u32>().map_err(|_| {
|
||||
io::Error::other(format!(
|
||||
"{} row {} has an invalid count",
|
||||
label,
|
||||
line_number + 1
|
||||
))
|
||||
})?;
|
||||
if count >= REQUIRED_SUBORDINATE_IDS
|
||||
&& start.checked_add(REQUIRED_SUBORDINATE_IDS - 1).is_some()
|
||||
{
|
||||
candidates.push(start);
|
||||
}
|
||||
}
|
||||
candidates.into_iter().min().ok_or_else(|| {
|
||||
io::Error::other(format!(
|
||||
"{label} has no contiguous {REQUIRED_SUBORDINATE_IDS}-ID allocation for the invoking user"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_ranges(
|
||||
name: &str,
|
||||
ranges: &[UserNamespaceIdRange],
|
||||
invoking_id: u32,
|
||||
) -> io::Result<()> {
|
||||
if ranges.len() != 2
|
||||
|| ranges[0]
|
||||
!= (UserNamespaceIdRange {
|
||||
container_id: 0,
|
||||
parent_id: invoking_id,
|
||||
size: 1,
|
||||
})
|
||||
|| ranges[1].container_id != 1
|
||||
|| ranges[1].size != REQUIRED_SUBORDINATE_IDS
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"{name} map does not match the canonical rootless shape"
|
||||
)));
|
||||
}
|
||||
for range in ranges {
|
||||
range
|
||||
.container_id
|
||||
.checked_add(range.size - 1)
|
||||
.and_then(|_| range.parent_id.checked_add(range.size - 1))
|
||||
.ok_or_else(|| io::Error::other(format!("{name} map overflows")))?;
|
||||
}
|
||||
let subordinate = ranges[1];
|
||||
if invoking_id >= subordinate.parent_id
|
||||
&& invoking_id - subordinate.parent_id < subordinate.size
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"{name} subordinate range contains the invoking identity"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn helper_arguments(pid: u32, ranges: &[UserNamespaceIdRange]) -> Vec<String> {
|
||||
std::iter::once(pid.to_string())
|
||||
.chain(ranges.iter().flat_map(|range| {
|
||||
[
|
||||
range.container_id.to_string(),
|
||||
range.parent_id.to_string(),
|
||||
range.size.to_string(),
|
||||
]
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn proc_text(ranges: &[UserNamespaceIdRange]) -> String {
|
||||
ranges.iter().fold(String::new(), |mut text, range| {
|
||||
writeln!(
|
||||
text,
|
||||
"{} {} {}",
|
||||
range.container_id, range.parent_id, range.size
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_proc_map(path: &str, expected: &[UserNamespaceIdRange], name: &str) -> io::Result<()> {
|
||||
let observed = std::fs::read_to_string(path)?
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let fields = line.split_whitespace().collect::<Vec<_>>();
|
||||
if fields.len() != 3 {
|
||||
return Err(io::Error::other(format!("{name} map row is malformed")));
|
||||
}
|
||||
Ok(UserNamespaceIdRange {
|
||||
container_id: fields[0]
|
||||
.parse()
|
||||
.map_err(|_| io::Error::other(format!("{name} container ID is invalid")))?,
|
||||
parent_id: fields[1]
|
||||
.parse()
|
||||
.map_err(|_| io::Error::other(format!("{name} parent ID is invalid")))?,
|
||||
size: fields[2]
|
||||
.parse()
|
||||
.map_err(|_| io::Error::other(format!("{name} range size is invalid")))?,
|
||||
})
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
if observed != expected {
|
||||
return Err(io::Error::other(format!(
|
||||
"live {name} map differs from the canonical map"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn helper_arguments_preserve_canonical_range_order() {
|
||||
let map = UserNamespaceMap {
|
||||
uid: vec![
|
||||
UserNamespaceIdRange {
|
||||
container_id: 0,
|
||||
parent_id: 1000,
|
||||
size: 1,
|
||||
},
|
||||
UserNamespaceIdRange {
|
||||
container_id: 1,
|
||||
parent_id: 524_288,
|
||||
size: 65_536,
|
||||
},
|
||||
],
|
||||
gid: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
map.uid_helper_arguments(42),
|
||||
["42", "0", "1000", "1", "1", "524288", "65536"]
|
||||
);
|
||||
assert_eq!(map.uid_proc_text(), "0 1000 1\n1 524288 65536\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subordinate_selection_uses_the_lowest_complete_allocation() {
|
||||
let names = BTreeSet::from(["1000".to_owned(), "developer".to_owned()]);
|
||||
assert_eq!(
|
||||
select_subordinate_range_contents(
|
||||
"/etc/subuid",
|
||||
"other:100000:65536\ndeveloper:700000:131072\n1000:524288:65536\n",
|
||||
&names,
|
||||
)
|
||||
.unwrap(),
|
||||
524_288
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subordinate_selection_refuses_partial_and_overflowing_allocations() {
|
||||
let names = BTreeSet::from(["developer".to_owned()]);
|
||||
let error = select_subordinate_range_contents(
|
||||
"/etc/subgid",
|
||||
"developer:524288:65535\ndeveloper:4294967295:65536\n",
|
||||
&names,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("no contiguous 65536-ID allocation")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ use lazaret_linux::process::{
|
|||
append_parent_identity, child_broker, current_process_identity, dispatch_private_process_role,
|
||||
verify_direct_child_pidfd,
|
||||
};
|
||||
use lazaret_linux::user_namespace::UserNamespaceMap;
|
||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||
use rustix::pipe::{PipeFlags, pipe_with};
|
||||
use rustix::process::{PidfdFlags, pidfd_open};
|
||||
|
|
@ -48,6 +49,7 @@ fn exit(result: std::io::Result<()>) -> ExitCode {
|
|||
|
||||
fn run_all() -> std::io::Result<()> {
|
||||
successful_acquisition()?;
|
||||
mapped_transient_uses_configured_subordinates()?;
|
||||
transient_captured_child_preserves_streams_and_exit()?;
|
||||
terminal_child_restores_slave_stdin_and_merges_output()?;
|
||||
target_stdin_does_not_gain_write_authority()?;
|
||||
|
|
@ -62,6 +64,13 @@ fn run_all() -> std::io::Result<()> {
|
|||
concurrent_acquisitions_serialize()
|
||||
}
|
||||
|
||||
fn mapped_transient_uses_configured_subordinates() -> std::io::Result<()> {
|
||||
let map = UserNamespaceMap::resolve_current()?;
|
||||
child_broker()
|
||||
.run_transient_in_user_namespace(&TargetCommand::new("/bin/true"), &map)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn target_stdin_does_not_gain_write_authority() -> std::io::Result<()> {
|
||||
let path = artifact_path(&format!(
|
||||
"lazaret-target-stdin-authority-{}-{}",
|
||||
|
|
|
|||
Loading…
Reference in a new issue